diff --git a/packages/twenty-website-new/public/illustrations/generated/home-background-bridge.png b/packages/twenty-website-new/public/illustrations/generated/home-background-bridge.png
new file mode 100644
index 00000000000..dd38e33190c
Binary files /dev/null and b/packages/twenty-website-new/public/illustrations/generated/home-background-bridge.png differ
diff --git a/packages/twenty-website-new/public/images/home/logo-bar/civicactions.svg b/packages/twenty-website-new/public/images/home/logo-bar/civicactions.svg
new file mode 100644
index 00000000000..cd8a15d68fb
Binary files /dev/null and b/packages/twenty-website-new/public/images/home/logo-bar/civicactions.svg differ
diff --git a/packages/twenty-website-new/public/images/home/logo-bar/fora.svg b/packages/twenty-website-new/public/images/home/logo-bar/fora.svg
new file mode 100644
index 00000000000..6faf2d6a650
Binary files /dev/null and b/packages/twenty-website-new/public/images/home/logo-bar/fora.svg differ
diff --git a/packages/twenty-website-new/public/images/home/logo-bar/nic.webp b/packages/twenty-website-new/public/images/home/logo-bar/nic.webp
new file mode 100644
index 00000000000..a3ff37cc7ce
Binary files /dev/null and b/packages/twenty-website-new/public/images/home/logo-bar/nic.webp differ
diff --git a/packages/twenty-website-new/public/images/home/logo-bar/otiima.svg b/packages/twenty-website-new/public/images/home/logo-bar/otiima.svg
new file mode 100644
index 00000000000..61d989ee8e0
Binary files /dev/null and b/packages/twenty-website-new/public/images/home/logo-bar/otiima.svg differ
diff --git a/packages/twenty-website-new/public/images/home/logo-bar/shiawase-home.webp b/packages/twenty-website-new/public/images/home/logo-bar/shiawase-home.webp
new file mode 100644
index 00000000000..2a755277273
Binary files /dev/null and b/packages/twenty-website-new/public/images/home/logo-bar/shiawase-home.webp differ
diff --git a/packages/twenty-website-new/public/images/home/logo-bar/wazoku.svg b/packages/twenty-website-new/public/images/home/logo-bar/wazoku.svg
new file mode 100644
index 00000000000..b69fbb23323
Binary files /dev/null and b/packages/twenty-website-new/public/images/home/logo-bar/wazoku.svg differ
diff --git a/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.png b/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.png
new file mode 100644
index 00000000000..4f10ae59f75
Binary files /dev/null and b/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.png differ
diff --git a/packages/twenty-website-new/public/images/home/logo-bar/xero.webp b/packages/twenty-website-new/public/images/home/logo-bar/windmill-original.webp
similarity index 100%
rename from packages/twenty-website-new/public/images/home/logo-bar/xero.webp
rename to packages/twenty-website-new/public/images/home/logo-bar/windmill-original.webp
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/claude.png b/packages/twenty-website-new/public/images/shared/companies/logos/claude.png
index 540789d4fff..b5913ed37e0 100644
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/claude.png and b/packages/twenty-website-new/public/images/shared/companies/logos/claude.png differ
diff --git a/packages/twenty-website-new/public/lottie/stepper/stepper.lottie b/packages/twenty-website-new/public/lottie/stepper/stepper.lottie
index 998752e97bf..873484e695d 100644
Binary files a/packages/twenty-website-new/public/lottie/stepper/stepper.lottie and b/packages/twenty-website-new/public/lottie/stepper/stepper.lottie differ
diff --git a/packages/twenty-website-new/src/app/(home)/_constants/hero.ts b/packages/twenty-website-new/src/app/(home)/_constants/hero.ts
index b33bdf67e35..bf9322fa80b 100644
--- a/packages/twenty-website-new/src/app/(home)/_constants/hero.ts
+++ b/packages/twenty-website-new/src/app/(home)/_constants/hero.ts
@@ -327,7 +327,7 @@ export const HERO_DATA: HeroHomeDataType = {
heading: [
{ text: 'Build', fontFamily: 'serif' },
{ text: ' your Enterprise CRM ', fontFamily: 'serif' },
- { text: 'at AI Speed', fontFamily: 'sans' },
+ { text: 'at\u00A0AI\u00A0Speed', fontFamily: 'sans' },
],
body: {
text: 'Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves.',
diff --git a/packages/twenty-website-new/src/app/(home)/_constants/three-cards-feature.ts b/packages/twenty-website-new/src/app/(home)/_constants/three-cards-feature.ts
index ab33265c204..d841503e88c 100644
--- a/packages/twenty-website-new/src/app/(home)/_constants/three-cards-feature.ts
+++ b/packages/twenty-website-new/src/app/(home)/_constants/three-cards-feature.ts
@@ -24,7 +24,7 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
illustration: 'familiar-interface',
},
{
- heading: { text: 'Live data and AI Built', fontFamily: 'sans' },
+ heading: { text: 'Live data and AI built', fontFamily: 'sans' },
body: {
text: 'Everything updates in real time, with AI chat always ready to help you move faster.',
},
diff --git a/packages/twenty-website-new/src/app/(home)/page.tsx b/packages/twenty-website-new/src/app/(home)/page.tsx
index 009e0335547..829b77b3520 100644
--- a/packages/twenty-website-new/src/app/(home)/page.tsx
+++ b/packages/twenty-website-new/src/app/(home)/page.tsx
@@ -32,6 +32,26 @@ export const metadata: Metadata = {
description: 'Modular, scalable open source CRM for modern teams.',
};
+const HeroHeadingGroup = styled.div`
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(3)};
+ width: 100%;
+
+ > *:nth-child(2) {
+ margin-top: 0;
+ }
+`;
+
+const HeroIntroGroup = styled.div`
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(8)};
+ width: 100%;
+`;
+
const ThreeCardsIllustrationIntroContent = styled.div`
display: grid;
grid-template-columns: 1fr;
@@ -73,7 +93,7 @@ export default async function HomePage() {
return (
<>
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/packages/twenty-website-new/src/app/_constants/trusted-by.ts b/packages/twenty-website-new/src/app/_constants/trusted-by.ts
index dff141bb7b6..47147b5bf6e 100644
--- a/packages/twenty-website-new/src/app/_constants/trusted-by.ts
+++ b/packages/twenty-website-new/src/app/_constants/trusted-by.ts
@@ -3,10 +3,31 @@ import type { TrustedByDataType } from '@/sections/TrustedBy/types';
export const TRUSTED_BY_DATA: TrustedByDataType = {
clientCountLabel: { text: '+10k others' },
logos: [
- { src: '/images/home/logo-bar/french-republic.webp' },
- { src: '/images/home/logo-bar/bayer.webp' },
- { fit: 'cover', src: '/images/home/logo-bar/pwc.webp' },
- { src: '/images/home/logo-bar/xero.webp' },
+ { grayOpacity: 0.52, src: '/images/home/logo-bar/french-republic.webp' },
+ {
+ grayBrightness: 0.9,
+ grayOpacity: 0.74,
+ src: '/images/home/logo-bar/bayer.webp',
+ },
+ {
+ fit: 'cover',
+ grayOpacity: 0.43,
+ src: '/images/home/logo-bar/pwc.webp',
+ },
+ {
+ grayOpacity: 0.7,
+ src: '/images/home/logo-bar/windmill-logo.png',
+ },
+ { grayOpacity: 0.48, src: '/images/home/logo-bar/fora.svg' },
+ { grayOpacity: 0.55, src: '/images/home/logo-bar/wazoku.svg' },
+ { grayOpacity: 0.68, src: '/images/home/logo-bar/civicactions.svg' },
+ { grayOpacity: 0.41, src: '/images/home/logo-bar/otiima.svg' },
+ { grayOpacity: 0.42, src: '/images/home/logo-bar/nic.webp' },
+ {
+ grayBrightness: 0.72,
+ grayOpacity: 0.74,
+ src: '/images/home/logo-bar/shiawase-home.webp',
+ },
],
separator: { text: 'trusted by' },
};
diff --git a/packages/twenty-website-new/src/app/halftone/_components/HalftoneCanvas.tsx b/packages/twenty-website-new/src/app/halftone/_components/HalftoneCanvas.tsx
index 713f0491d62..e84e7c64031 100644
--- a/packages/twenty-website-new/src/app/halftone/_components/HalftoneCanvas.tsx
+++ b/packages/twenty-website-new/src/app/halftone/_components/HalftoneCanvas.tsx
@@ -236,8 +236,11 @@ const halftoneFragmentShader = `
if (applyToDarkAreas > 0.5) {
toneValue = 1.0 - toneValue;
}
+ // Preserve the pre-toneTarget light-mode response by keeping the power
+ // bias inside the averaged tone calculation.
+ float powerBias = localPower * length(vec2(0.5)) * (1.0 / 3.0);
float bandRadius = clamp(
- toneValue + localPower * length(vec2(0.5)) + lightLift,
+ toneValue + powerBias + lightLift,
0.0,
1.0
) * 1.86 * 0.5;
@@ -721,6 +724,10 @@ export function HalftoneCanvas({
let animationFrameId = 0;
let cancelled = false;
+ let isVisible =
+ typeof document === 'undefined' ? true : !document.hidden;
+ let isIntersecting = true;
+ const shouldRender = () => isVisible && isIntersecting;
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
@@ -1562,7 +1569,9 @@ export function HalftoneCanvas({
return;
}
- animationFrameId = window.requestAnimationFrame(renderFrame);
+ animationFrameId = shouldRender()
+ ? window.requestAnimationFrame(renderFrame)
+ : 0;
clock.update(timestamp);
const interaction = interactionReference.current;
@@ -1984,10 +1993,43 @@ export function HalftoneCanvas({
renderer.render(postScene, orthographicCamera);
};
+ const resumeIfNeeded = () => {
+ if (!cancelled && shouldRender() && animationFrameId === 0) {
+ animationFrameId = window.requestAnimationFrame(renderFrame);
+ }
+ };
+
+ const handleVisibilityChange = () => {
+ isVisible = !document.hidden;
+ if (!shouldRender() && animationFrameId !== 0) {
+ window.cancelAnimationFrame(animationFrameId);
+ animationFrameId = 0;
+ } else {
+ resumeIfNeeded();
+ }
+ };
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+
+ const intersectionObserver = new IntersectionObserver(
+ (entries) => {
+ isIntersecting = entries.some((entry) => entry.isIntersecting);
+ if (!shouldRender() && animationFrameId !== 0) {
+ window.cancelAnimationFrame(animationFrameId);
+ animationFrameId = 0;
+ } else {
+ resumeIfNeeded();
+ }
+ },
+ { rootMargin: '100px' },
+ );
+ intersectionObserver.observe(container);
+
renderFrame();
cleanup = () => {
resizeObserver.disconnect();
+ intersectionObserver.disconnect();
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
canvas.removeEventListener('pointermove', handlePointerMove);
canvas.removeEventListener('pointerleave', handlePointerLeave);
canvas.removeEventListener('pointerup', handlePointerUp);
diff --git a/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts b/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts
index 63de5843fe5..21da7c51154 100644
--- a/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts
+++ b/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts
@@ -251,8 +251,11 @@ const halftoneFragmentShader = `
if (applyToDarkAreas > 0.5) {
toneValue = 1.0 - toneValue;
}
+ // Preserve the pre-toneTarget light-mode response by keeping the power
+ // bias inside the averaged tone calculation.
+ float powerBias = localPower * length(vec2(0.5)) * (1.0 / 3.0);
float bandRadius = clamp(
- toneValue + localPower * length(vec2(0.5)) + lightLift,
+ toneValue + powerBias + lightLift,
0.0,
1.0
) * 1.86 * 0.5;
diff --git a/packages/twenty-website-new/src/app/halftone/_lib/imageSvgExport.ts b/packages/twenty-website-new/src/app/halftone/_lib/imageSvgExport.ts
index 45a65aed42c..495ae150700 100644
--- a/packages/twenty-website-new/src/app/halftone/_lib/imageSvgExport.ts
+++ b/packages/twenty-website-new/src/app/halftone/_lib/imageSvgExport.ts
@@ -203,8 +203,10 @@ export function generateImageHalftoneSvg({
toneValue = 1 - toneValue;
}
+ // Preserve the pre-toneTarget light-mode response by keeping the power
+ // bias inside the averaged tone calculation.
const bandRadius =
- clamp(toneValue + localPower * Math.SQRT1_2, 0, 1) * 0.93;
+ clamp(toneValue + (localPower * Math.SQRT1_2) / 3, 0, 1) * 0.93;
if (bandRadius <= 0.0001) {
continue;
diff --git a/packages/twenty-website-new/src/design-system/components/Button/BaseButton.tsx b/packages/twenty-website-new/src/design-system/components/Button/BaseButton.tsx
index a4a73201002..ec0a2e0dacb 100644
--- a/packages/twenty-website-new/src/design-system/components/Button/BaseButton.tsx
+++ b/packages/twenty-website-new/src/design-system/components/Button/BaseButton.tsx
@@ -2,6 +2,13 @@ import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { ButtonShape } from './ButtonShape';
+export type ButtonSize = 'regular' | 'small';
+
+export const BUTTON_HEIGHTS_PX: Record = {
+ regular: 40,
+ small: 32,
+};
+
export const buttonBaseStyles = `
--button-label-color: ${theme.colors.primary.text[100]};
--button-label-hover-color: ${theme.colors.secondary.text[100]};
@@ -15,7 +22,7 @@ export const buttonBaseStyles = `
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
- height: ${theme.spacing(10)};
+ height: ${BUTTON_HEIGHTS_PX.regular}px;
justify-content: center;
letter-spacing: 0;
overflow: hidden;
@@ -24,6 +31,11 @@ export const buttonBaseStyles = `
text-decoration: none;
text-transform: uppercase;
+ &[data-size='small'] {
+ height: ${BUTTON_HEIGHTS_PX.small}px;
+ padding: 0 ${theme.spacing(4)};
+ }
+
&[data-variant='contained'][data-color='secondary'] {
--button-label-color: ${theme.colors.secondary.text[100]};
--button-label-hover-color: ${theme.colors.secondary.text[100]};
@@ -67,6 +79,7 @@ const Label = styled.span`
export type BaseButtonProps = {
color: 'primary' | 'secondary';
label: string;
+ size?: ButtonSize;
variant: 'contained' | 'outlined';
};
@@ -88,7 +101,12 @@ const HoverFill = styled.span`
}
`;
-export function BaseButton({ color, label, variant }: BaseButtonProps) {
+export function BaseButton({
+ color,
+ label,
+ size = 'regular',
+ variant,
+}: BaseButtonProps) {
let fillColor: string;
let hoverFillColor: string;
let hoverFillOpacity = 1;
@@ -120,15 +138,18 @@ export function BaseButton({ color, label, variant }: BaseButtonProps) {
throw new Error(`Unhandled button appearance: ${variant} ${color}`);
}
+ const height = BUTTON_HEIGHTS_PX[size];
+
return (
<>
-
+
>
diff --git a/packages/twenty-website-new/src/design-system/components/Button/ButtonShape.tsx b/packages/twenty-website-new/src/design-system/components/Button/ButtonShape.tsx
index 9b4454ace3f..e9054dce3af 100644
--- a/packages/twenty-website-new/src/design-system/components/Button/ButtonShape.tsx
+++ b/packages/twenty-website-new/src/design-system/components/Button/ButtonShape.tsx
@@ -1,20 +1,46 @@
-import { styled } from "@linaria/react";
+import { styled } from '@linaria/react';
-interface ButtonShapeProps {
+type ButtonShapeProps = {
dataSlot?: string;
fillColor: string;
+ height: number;
strokeColor: string;
+};
+
+// The bottom-right corner taper is a fixed design element; only the straight
+// vertical segment between top-right arc and the taper changes with height.
+const TAPER_HEIGHT = 15.477;
+const TAPER_TOP_OFFSET = 4;
+const STRAIGHT_V_AT_FORTY = 20.523;
+
+function getLeftFillPath(height: number) {
+ return `M4 0 A4 4 0 0 0 0 4 V${height - 4} A4 4 0 0 0 4 ${height} Z`;
}
-const LEFT_FILL = "M4 0 A4 4 0 0 0 0 4 V36 A4 4 0 0 0 4 40 Z";
-const LEFT_OUTLINE = "M4 0.5 A3.5 3.5 0 0 0 0.5 4 V36 A3.5 3.5 0 0 0 4 39.5";
+function getLeftOutlinePath(height: number) {
+ return `M4 0.5 A3.5 3.5 0 0 0 0.5 4 V${height - 4} A3.5 3.5 0 0 0 4 ${height - 0.5}`;
+}
-const RIGHT_FILL = "M0 0 h11 a4 4 0 0 1 4 4 v20.523 a6 6 0 0 1 -1.544 4.019 l-8.548 9.477 A6 6 0 0 1 0.453 40 H0 Z";
-const RIGHT_OUTLINE = "M0 0.5 h11 a3.5 3.5 0 0 1 3.5 3.5 v20.523 a5.5 5.5 0 0 1 -1.416 3.684 l-8.547 9.477 a5.5 5.5 0 0 1 -4.084 1.816 H0";
+function getRightFillPath(height: number) {
+ const straight = Math.max(
+ height - TAPER_TOP_OFFSET - TAPER_HEIGHT,
+ 0,
+ );
+ return `M0 0 h11 a4 4 0 0 1 4 4 v${straight} a6 6 0 0 1 -1.544 4.019 l-8.548 9.477 A6 6 0 0 1 0.453 ${height} H0 Z`;
+}
+
+function getRightOutlinePath(height: number) {
+ const straight = Math.max(
+ height - TAPER_TOP_OFFSET - TAPER_HEIGHT,
+ 0,
+ );
+ return `M0 0.5 h11 a3.5 3.5 0 0 1 3.5 3.5 v${straight} a5.5 5.5 0 0 1 -1.416 3.684 l-8.547 9.477 a5.5 5.5 0 0 1 -4.084 1.816 H0`;
+}
+
+void STRAIGHT_V_AT_FORTY;
const ShapeContainer = styled.div`
display: flex;
- height: 40px;
inset: 0;
pointer-events: none;
position: absolute;
@@ -42,36 +68,96 @@ const RightCap = styled.svg`
export function ButtonShape({
dataSlot,
fillColor,
+ height,
strokeColor,
}: ButtonShapeProps) {
- const isOutline = fillColor === "none";
+ const isOutline = fillColor === 'none';
return (
-
-
+
+
{isOutline ? (
-
+
) : (
-
+
)}
-
+
{isOutline ? (
<>
-
-
+
+
>
) : (
-
+
)}
-
+
{isOutline ? (
-
+
) : (
-
+
)}
diff --git a/packages/twenty-website-new/src/design-system/components/Button/LinkButton.tsx b/packages/twenty-website-new/src/design-system/components/Button/LinkButton.tsx
index 1736a2080d7..617b09c0133 100644
--- a/packages/twenty-website-new/src/design-system/components/Button/LinkButton.tsx
+++ b/packages/twenty-website-new/src/design-system/components/Button/LinkButton.tsx
@@ -24,15 +24,19 @@ export function LinkButton({
color,
href,
label,
+ size = 'regular',
type,
variant,
}: LinkButtonProps) {
- const inner = ;
+ const inner = (
+
+ );
if (type === 'anchor') {
return (
+
{inner}
);
diff --git a/packages/twenty-website-new/src/design-system/components/Button/SubmitButton.tsx b/packages/twenty-website-new/src/design-system/components/Button/SubmitButton.tsx
index 357b0873ab0..7253884789d 100644
--- a/packages/twenty-website-new/src/design-system/components/Button/SubmitButton.tsx
+++ b/packages/twenty-website-new/src/design-system/components/Button/SubmitButton.tsx
@@ -17,16 +17,18 @@ export function SubmitButton({
color,
label,
onClick,
+ size = 'regular',
variant,
}: SubmitButtonProps) {
return (
-
+
);
}
diff --git a/packages/twenty-website-new/src/illustrations/Helped/HelpedHalftoneModel.tsx b/packages/twenty-website-new/src/illustrations/Helped/HelpedHalftoneModel.tsx
index 67523ba24a5..f3a516c688d 100644
--- a/packages/twenty-website-new/src/illustrations/Helped/HelpedHalftoneModel.tsx
+++ b/packages/twenty-website-new/src/illustrations/Helped/HelpedHalftoneModel.tsx
@@ -268,8 +268,11 @@ const halftoneFragmentShader = /* glsl */ `
if (applyToDarkAreas > 0.5) {
toneValue = 1.0 - toneValue;
}
+ // Preserve the pre-toneTarget light-mode response by keeping the power
+ // bias inside the averaged tone calculation.
+ float powerBias = s_3 * length(vec2(0.5)) * (1.0 / 3.0);
float bandRadius = clamp(
- toneValue + s_3 * length(vec2(0.5)) + lightLift,
+ toneValue + powerBias + lightLift,
0.0,
1.0
) * 1.86 * 0.5;
diff --git a/packages/twenty-website-new/src/illustrations/Hero/HomeBackgroundHalftone.tsx b/packages/twenty-website-new/src/illustrations/Hero/HomeBackgroundHalftone.tsx
new file mode 100644
index 00000000000..44a18a17378
--- /dev/null
+++ b/packages/twenty-website-new/src/illustrations/Hero/HomeBackgroundHalftone.tsx
@@ -0,0 +1,725 @@
+'use client';
+
+import { createSiteWebGlRenderer } from '@/lib/webgl';
+import { styled } from '@linaria/react';
+import { useEffect, useRef, useState } from 'react';
+import * as THREE from 'three';
+
+const HOME_BACKGROUND_IMAGE_URL =
+ '/illustrations/generated/home-background-bridge.png';
+
+const REFERENCE_PREVIEW_DISTANCE = 4;
+
+type HomeBackgroundBreakpoint = 'mobile' | 'tablet' | 'desktop';
+
+type HomeBackgroundTuneValues = {
+ horizontalOffsetPx: number;
+ previewDistance: number;
+ verticalAnchor: number;
+ verticalOffsetPx: number;
+};
+
+const HOME_BACKGROUND_BREAKPOINT_MAX_WIDTHS: Record<
+ Exclude,
+ number
+> = {
+ mobile: 767,
+ tablet: 1199,
+};
+
+const HOME_BACKGROUND_TUNE_VALUES: Record<
+ HomeBackgroundBreakpoint,
+ HomeBackgroundTuneValues
+> = {
+ desktop: {
+ horizontalOffsetPx: -151,
+ previewDistance: 3.2,
+ verticalAnchor: 0.5,
+ verticalOffsetPx: 224,
+ },
+ mobile: {
+ horizontalOffsetPx: -60,
+ previewDistance: 3.2,
+ verticalAnchor: 0,
+ verticalOffsetPx: 0,
+ },
+ tablet: {
+ horizontalOffsetPx: -120,
+ previewDistance: 3.2,
+ verticalAnchor: 0.5,
+ verticalOffsetPx: 0,
+ },
+};
+
+function getHomeBackgroundBreakpoint(
+ viewportWidth: number,
+): HomeBackgroundBreakpoint {
+ if (viewportWidth <= HOME_BACKGROUND_BREAKPOINT_MAX_WIDTHS.mobile) {
+ return 'mobile';
+ }
+ if (viewportWidth <= HOME_BACKGROUND_BREAKPOINT_MAX_WIDTHS.tablet) {
+ return 'tablet';
+ }
+ return 'desktop';
+}
+
+function getActiveTuneValues(): HomeBackgroundTuneValues {
+ const key =
+ typeof window !== 'undefined'
+ ? getHomeBackgroundBreakpoint(window.innerWidth)
+ : 'desktop';
+ return HOME_BACKGROUND_TUNE_VALUES[key];
+}
+const VIRTUAL_RENDER_HEIGHT = 768;
+const MIN_FOOTPRINT_SCALE = 0.001;
+
+const HALFTONE_EDGE_FADE_X = 0;
+const HALFTONE_EDGE_FADE_Y = 0;
+const HALFTONE_TILE_SIZE = 12;
+const HALFTONE_POWER = -0.07;
+const HALFTONE_WIDTH = 0.34;
+const HALFTONE_CONTRAST = 1;
+const HALFTONE_DASH_COLOR = '#4A38F5';
+const HALFTONE_HOVER_COLOR = '#4A38F5';
+const HALFTONE_HOVER_LIGHT_INTENSITY = 0.8;
+const HALFTONE_HOVER_LIGHT_RADIUS = 0.14;
+const HALFTONE_HOVER_VERTICAL_FADE = 0.5;
+const HALFTONE_HOVER_FADE_IN = 18;
+const HALFTONE_HOVER_FADE_OUT = 7;
+
+const IMAGE_POINTER_FOLLOW = 0.38;
+const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
+
+const passThroughVertexShader = `
+ varying vec2 vUv;
+
+ void main() {
+ vUv = uv;
+ gl_Position = vec4(position, 1.0);
+ }
+`;
+
+const imagePassthroughFragmentShader = `
+ precision highp float;
+
+ uniform sampler2D tImage;
+ uniform vec2 imageSize;
+ uniform vec2 viewportSize;
+ uniform float zoom;
+ uniform float contrast;
+ uniform float verticalPixelOffset;
+ uniform float horizontalPixelOffset;
+ uniform float verticalAnchor;
+
+ varying vec2 vUv;
+
+ void main() {
+ float imageAspect = imageSize.x / imageSize.y;
+ float viewAspect = viewportSize.x / viewportSize.y;
+
+ vec2 uv = vUv;
+
+ // Always fit by width: the full horizontal span of the image is visible at
+ // any container aspect ratio. Wider containers crop top/bottom of the
+ // image; taller containers letterbox. verticalAnchor controls which edge
+ // the letterboxed image sits against (0 = bottom, 0.5 = center, 1 = top).
+ uv.y = (uv.y - verticalAnchor) * (imageAspect / viewAspect) + verticalAnchor;
+
+ uv = (uv - 0.5) / zoom + 0.5;
+
+ // Shift sampled image content on the canvas by the pixel offsets.
+ uv.y += verticalPixelOffset / max(viewportSize.y, 1.0);
+ uv.x -= horizontalPixelOffset / max(viewportSize.x, 1.0);
+
+ float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
+ * step(0.0, uv.y) * step(uv.y, 1.0);
+
+ vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
+ vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
+
+ gl_FragColor = vec4(contrastColor, inBounds);
+ }
+`;
+
+const halftoneFragmentShader = `
+ precision highp float;
+
+ uniform sampler2D tScene;
+ uniform vec2 effectResolution;
+ uniform vec2 logicalResolution;
+ uniform float tile;
+ uniform float s_3;
+ uniform float s_4;
+ uniform float applyToDarkAreas;
+ uniform vec3 dashColor;
+ uniform vec3 hoverDashColor;
+ uniform float footprintScale;
+ uniform vec2 interactionUv;
+ uniform float hoverLightStrength;
+ uniform float hoverLightRadius;
+ uniform float hoverVerticalFade;
+ uniform float cropToBounds;
+ uniform vec2 edgeFade;
+
+ varying vec2 vUv;
+
+ float distSegment(in vec2 p, in vec2 a, in vec2 b) {
+ vec2 pa = p - a;
+ vec2 ba = b - a;
+ float denom = max(dot(ba, ba), 0.000001);
+ float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
+ return length(pa - ba * h);
+ }
+
+ float lineSimpleEt(in vec2 p, in float r, in float thickness) {
+ vec2 a = vec2(0.5) + vec2(-r, 0.0);
+ vec2 b = vec2(0.5) + vec2(r, 0.0);
+ float distToSegment = distSegment(p, a, b);
+ float halfThickness = thickness * r;
+ return distToSegment - halfThickness;
+ }
+
+ void main() {
+ if (cropToBounds > 0.5) {
+ vec4 boundsCheck = texture2D(tScene, vUv);
+ if (boundsCheck.a < 0.01) {
+ gl_FragColor = vec4(0.0);
+ return;
+ }
+ }
+
+ vec2 fragCoord =
+ (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
+ float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);
+ vec2 pointerPx = interactionUv * logicalResolution;
+ vec2 fragDelta = fragCoord - pointerPx;
+ float fragDist = length(fragDelta);
+
+ float hoverLightMask = 0.0;
+ if (hoverLightStrength > 0.0) {
+ float lightRadiusPx = hoverLightRadius * logicalResolution.y;
+ hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
+ float fadeRange = max(hoverVerticalFade, 0.0001);
+ float verticalHoverFade =
+ smoothstep(0.0, fadeRange, vUv.y) *
+ smoothstep(0.0, fadeRange, 1.0 - vUv.y);
+ hoverLightMask *= verticalHoverFade;
+ }
+
+ vec2 effectCoord = fragCoord;
+
+ vec2 cellIndex = floor(effectCoord / halftoneSize);
+ vec2 sampleUv = clamp(
+ (cellIndex + 0.5) * halftoneSize / logicalResolution,
+ vec2(0.0),
+ vec2(1.0)
+ );
+ vec2 cellUv = fract(effectCoord / halftoneSize);
+
+ vec4 sceneSample = texture2D(tScene, sampleUv);
+ float mask = smoothstep(0.02, 0.08, sceneSample.a);
+ float localPower = clamp(s_3, -1.5, 1.5);
+ float localWidth = clamp(s_4, 0.05, 1.4);
+ float lightLift = hoverLightStrength * hoverLightMask * 0.22;
+ float toneValue =
+ (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0);
+ if (applyToDarkAreas > 0.5) {
+ toneValue = 1.0 - toneValue;
+ }
+ float bandRadius = clamp(
+ toneValue + localPower * length(vec2(0.5)) + lightLift,
+ 0.0,
+ 1.0
+ ) * 1.86 * 0.5;
+
+ float alpha = 0.0;
+ if (bandRadius > 0.0001) {
+ float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);
+ float edge = 0.02;
+ alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
+ }
+
+ // Fade halftone alpha toward the canvas edges so hovering into / out of
+ // the region is a gradual visual transition rather than a hard cutoff.
+ float edgeFadeMask =
+ smoothstep(0.0, max(edgeFade.x, 0.0001), vUv.x) *
+ smoothstep(0.0, max(edgeFade.x, 0.0001), 1.0 - vUv.x) *
+ smoothstep(0.0, max(edgeFade.y, 0.0001), vUv.y) *
+ smoothstep(0.0, max(edgeFade.y, 0.0001), 1.0 - vUv.y);
+ alpha *= edgeFadeMask;
+
+ vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverLightMask);
+ vec3 color = activeDashColor * alpha;
+ gl_FragColor = vec4(color, alpha);
+
+ #include
+ #include
+ }
+`;
+
+const StyledMount = styled.div<{ $isReady: boolean }>`
+ height: calc(100% + 80px);
+ inset: -40px;
+ opacity: ${({ $isReady }) => ($isReady ? 1 : 0)};
+ position: absolute;
+ transition: opacity 600ms ease;
+ width: calc(100% + 80px);
+`;
+
+type Rect = { height: number; width: number; x: number; y: number };
+
+function clampRectToViewport(
+ rect: Rect,
+ viewportWidth: number,
+ viewportHeight: number,
+): Rect | null {
+ const minX = Math.max(rect.x, 0);
+ const minY = Math.max(rect.y, 0);
+ const maxX = Math.min(rect.x + rect.width, viewportWidth);
+ const maxY = Math.min(rect.y + rect.height, viewportHeight);
+
+ if (maxX <= minX || maxY <= minY) {
+ return null;
+ }
+
+ return {
+ height: maxY - minY,
+ width: maxX - minX,
+ x: minX,
+ y: minY,
+ };
+}
+
+function getRectArea(rect: Rect | null) {
+ if (!rect) {
+ return 0;
+ }
+
+ return Math.max(rect.width, 0) * Math.max(rect.height, 0);
+}
+
+function getImagePreviewZoom(previewDistance: number) {
+ return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
+}
+
+function getContainedImageRect({
+ imageHeight,
+ imageWidth,
+ viewportHeight,
+ viewportWidth,
+ zoom,
+}: {
+ imageHeight: number;
+ imageWidth: number;
+ viewportHeight: number;
+ viewportWidth: number;
+ zoom: number;
+}): Rect | null {
+ if (
+ imageWidth <= 0 ||
+ imageHeight <= 0 ||
+ viewportWidth <= 0 ||
+ viewportHeight <= 0
+ ) {
+ return null;
+ }
+
+ const imageAspect = imageWidth / imageHeight;
+
+ // Width-fit: the shader always stretches the full horizontal span of the
+ // image to the viewport, then letterboxes / crops vertically. Mirror that
+ // here so footprint scaling stays consistent across aspect ratios.
+ const fittedWidth = viewportWidth;
+ const fittedHeight = viewportWidth / imageAspect;
+
+ const scaledWidth = fittedWidth * zoom;
+ const scaledHeight = fittedHeight * zoom;
+
+ return clampRectToViewport(
+ {
+ height: scaledHeight,
+ width: scaledWidth,
+ x: (viewportWidth - scaledWidth) * 0.5,
+ y: (viewportHeight - scaledHeight) * 0.5,
+ },
+ viewportWidth,
+ viewportHeight,
+ );
+}
+
+function getFootprintScaleFromRects(
+ currentRect: Rect | null,
+ referenceRect: Rect | null,
+) {
+ const currentArea = getRectArea(currentRect);
+ const referenceArea = getRectArea(referenceRect);
+
+ if (currentArea <= 0 || referenceArea <= 0) {
+ return 1;
+ }
+
+ return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE);
+}
+
+function getImageFootprintScale({
+ imageHeight,
+ imageWidth,
+ previewDistance,
+ viewportHeight,
+ viewportWidth,
+}: {
+ imageHeight: number;
+ imageWidth: number;
+ previewDistance: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}) {
+ const currentRect = getContainedImageRect({
+ imageHeight,
+ imageWidth,
+ viewportHeight,
+ viewportWidth,
+ zoom: getImagePreviewZoom(previewDistance),
+ });
+ const referenceRect = getContainedImageRect({
+ imageHeight,
+ imageWidth,
+ viewportHeight,
+ viewportWidth,
+ zoom: 1,
+ });
+
+ return getFootprintScaleFromRects(currentRect, referenceRect);
+}
+
+function createRenderTarget(width: number, height: number) {
+ return new THREE.WebGLRenderTarget(width, height, {
+ format: THREE.RGBAFormat,
+ magFilter: THREE.LinearFilter,
+ minFilter: THREE.LinearFilter,
+ });
+}
+
+function loadImage(imageUrl: string): Promise {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ image.decoding = 'async';
+ image.onload = () => resolve(image);
+ image.onerror = () =>
+ reject(new Error(`Failed to load home background image: ${imageUrl}`));
+ image.src = imageUrl;
+ });
+}
+
+type PointerState = {
+ hoverStrength: number;
+ mouseX: number;
+ mouseY: number;
+ pointerInside: boolean;
+ smoothedMouseX: number;
+ smoothedMouseY: number;
+};
+
+async function mountHomeBackgroundCanvas({
+ container,
+ imageUrl,
+}: {
+ container: HTMLDivElement;
+ imageUrl: string;
+}): Promise<() => void> {
+ const image = await loadImage(imageUrl);
+
+ const getWidth = () => Math.max(container.clientWidth, 1);
+ const getHeight = () => Math.max(container.clientHeight, 1);
+ const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
+ const getVirtualWidth = () =>
+ Math.max(
+ Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
+ 1,
+ );
+
+ const renderer = createSiteWebGlRenderer({
+ alpha: true,
+ antialias: false,
+ powerPreference: 'high-performance',
+ });
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.setPixelRatio(1);
+ renderer.setClearColor(0x000000, 0);
+ renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
+
+ const canvas = renderer.domElement;
+ canvas.setAttribute('aria-hidden', 'true');
+ canvas.style.display = 'block';
+ canvas.style.height = '100%';
+ canvas.style.pointerEvents = 'none';
+ canvas.style.width = '100%';
+ container.appendChild(canvas);
+
+ const imageTexture = new THREE.Texture(image);
+ imageTexture.colorSpace = THREE.SRGBColorSpace;
+ imageTexture.generateMipmaps = false;
+ imageTexture.magFilter = THREE.LinearFilter;
+ imageTexture.minFilter = THREE.LinearFilter;
+ imageTexture.needsUpdate = true;
+
+ const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
+ const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
+ const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
+
+ const initialTune = getActiveTuneValues();
+ const imageMaterial = new THREE.ShaderMaterial({
+ fragmentShader: imagePassthroughFragmentShader,
+ uniforms: {
+ contrast: { value: HALFTONE_CONTRAST },
+ horizontalPixelOffset: { value: initialTune.horizontalOffsetPx },
+ imageSize: { value: new THREE.Vector2(image.width, image.height) },
+ tImage: { value: imageTexture },
+ verticalAnchor: { value: initialTune.verticalAnchor },
+ verticalPixelOffset: { value: initialTune.verticalOffsetPx },
+ viewportSize: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ zoom: {
+ value: getImagePreviewZoom(initialTune.previewDistance),
+ },
+ },
+ vertexShader: passThroughVertexShader,
+ });
+
+ const imageScene = new THREE.Scene();
+ imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
+
+ const halftoneMaterial = new THREE.ShaderMaterial({
+ fragmentShader: halftoneFragmentShader,
+ transparent: true,
+ uniforms: {
+ applyToDarkAreas: { value: 1 },
+ cropToBounds: { value: 1 },
+ dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) },
+ edgeFade: {
+ value: new THREE.Vector2(HALFTONE_EDGE_FADE_X, HALFTONE_EDGE_FADE_Y),
+ },
+ effectResolution: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ footprintScale: { value: 1 },
+ hoverDashColor: { value: new THREE.Color(HALFTONE_HOVER_COLOR) },
+ hoverLightRadius: { value: HALFTONE_HOVER_LIGHT_RADIUS },
+ hoverLightStrength: { value: 0 },
+ hoverVerticalFade: { value: HALFTONE_HOVER_VERTICAL_FADE },
+ interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
+ logicalResolution: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ s_3: { value: HALFTONE_POWER },
+ s_4: { value: HALFTONE_WIDTH },
+ tScene: { value: sceneTarget.texture },
+ tile: { value: HALFTONE_TILE_SIZE },
+ },
+ vertexShader: passThroughVertexShader,
+ });
+
+ const postScene = new THREE.Scene();
+ postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
+
+ const updateViewportUniforms = (
+ logicalWidth: number,
+ logicalHeight: number,
+ effectWidth: number,
+ effectHeight: number,
+ ) => {
+ halftoneMaterial.uniforms.effectResolution.value.set(
+ effectWidth,
+ effectHeight,
+ );
+ halftoneMaterial.uniforms.logicalResolution.value.set(
+ logicalWidth,
+ logicalHeight,
+ );
+ imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
+ };
+
+ const syncSize = () => {
+ const virtualWidth = getVirtualWidth();
+ const virtualHeight = getVirtualHeight();
+
+ renderer.setSize(virtualWidth, virtualHeight, false);
+ sceneTarget.setSize(virtualWidth, virtualHeight);
+ updateViewportUniforms(
+ virtualWidth,
+ virtualHeight,
+ virtualWidth,
+ virtualHeight,
+ );
+ };
+
+ const resizeObserver = new ResizeObserver(syncSize);
+ resizeObserver.observe(container);
+
+ const pointer: PointerState = {
+ hoverStrength: 0,
+ mouseX: 0.5,
+ mouseY: 0.5,
+ pointerInside: false,
+ smoothedMouseX: 0.5,
+ smoothedMouseY: 0.5,
+ };
+
+ const updatePointerPosition = (event: PointerEvent) => {
+ const rect = container.getBoundingClientRect();
+ const width = Math.max(rect.width, 1);
+ const height = Math.max(rect.height, 1);
+
+ // Track the cursor in unclamped container-relative coordinates so the
+ // shader's radial falloff can fade the hover light smoothly as the cursor
+ // approaches or leaves the halftone region, instead of snapping on/off at
+ // the container edge.
+ pointer.mouseX = (event.clientX - rect.left) / width;
+ pointer.mouseY = (event.clientY - rect.top) / height;
+ pointer.pointerInside = true;
+ };
+
+ const handlePointerMove = (event: PointerEvent) => {
+ const target = event.target as HTMLElement | null;
+ if (target?.closest('[data-halftone-exclude]')) {
+ pointer.pointerInside = false;
+ return;
+ }
+ updatePointerPosition(event);
+ };
+
+ const handlePointerLeave = () => {
+ pointer.pointerInside = false;
+ };
+
+ window.addEventListener('pointermove', handlePointerMove);
+ window.addEventListener('pointerleave', handlePointerLeave);
+ window.addEventListener('blur', handlePointerLeave);
+
+ const clock = new THREE.Clock();
+ let animationFrameId = 0;
+
+ const getHalftoneFootprintScale = () =>
+ getImageFootprintScale({
+ imageHeight: image.height,
+ imageWidth: image.width,
+ previewDistance: getActiveTuneValues().previewDistance,
+ viewportHeight: getVirtualHeight(),
+ viewportWidth: getVirtualWidth(),
+ });
+
+ const renderFrame = () => {
+ animationFrameId = window.requestAnimationFrame(renderFrame);
+ const deltaSeconds = clock.getDelta();
+
+ const hoverEasing =
+ 1 -
+ Math.exp(
+ -deltaSeconds *
+ (pointer.pointerInside
+ ? HALFTONE_HOVER_FADE_IN
+ : HALFTONE_HOVER_FADE_OUT),
+ );
+ pointer.hoverStrength +=
+ ((pointer.pointerInside ? 1 : 0) - pointer.hoverStrength) * hoverEasing;
+
+ pointer.smoothedMouseX +=
+ (pointer.mouseX - pointer.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
+ pointer.smoothedMouseY +=
+ (pointer.mouseY - pointer.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
+
+ halftoneMaterial.uniforms.interactionUv.value.set(
+ pointer.smoothedMouseX,
+ 1 - pointer.smoothedMouseY,
+ );
+ halftoneMaterial.uniforms.hoverLightStrength.value =
+ HALFTONE_HOVER_LIGHT_INTENSITY * pointer.hoverStrength;
+ const active = getActiveTuneValues();
+ imageMaterial.uniforms.zoom.value = getImagePreviewZoom(
+ active.previewDistance,
+ );
+ imageMaterial.uniforms.verticalPixelOffset.value = active.verticalOffsetPx;
+ imageMaterial.uniforms.horizontalPixelOffset.value =
+ active.horizontalOffsetPx;
+ imageMaterial.uniforms.verticalAnchor.value = active.verticalAnchor;
+ halftoneMaterial.uniforms.footprintScale.value =
+ getHalftoneFootprintScale();
+
+ renderer.setRenderTarget(sceneTarget);
+ renderer.render(imageScene, orthographicCamera);
+
+ renderer.setRenderTarget(null);
+ renderer.clear();
+ renderer.render(postScene, orthographicCamera);
+ };
+
+ // Use pointer velocity damping constant (reserved for future use to keep
+ // parity with the partner halftone overlay — kept to avoid linter noise).
+ void IMAGE_POINTER_VELOCITY_DAMPING;
+
+ renderFrame();
+
+ return () => {
+ window.cancelAnimationFrame(animationFrameId);
+ resizeObserver.disconnect();
+ window.removeEventListener('pointermove', handlePointerMove);
+ window.removeEventListener('pointerleave', handlePointerLeave);
+ window.removeEventListener('blur', handlePointerLeave);
+ halftoneMaterial.dispose();
+ imageMaterial.dispose();
+ imageTexture.dispose();
+ fullScreenGeometry.dispose();
+ sceneTarget.dispose();
+ renderer.dispose();
+
+ if (canvas.parentNode === container) {
+ container.removeChild(canvas);
+ }
+ };
+}
+
+export function HomeBackgroundHalftone() {
+ const mountReference = useRef(null);
+ const [isReady, setIsReady] = useState(false);
+
+ useEffect(() => {
+ const container = mountReference.current;
+
+ if (!container) {
+ return;
+ }
+
+ let disposed = false;
+ let unmount: (() => void) | null = null;
+ let readyFrameId = 0;
+
+ mountHomeBackgroundCanvas({
+ container,
+ imageUrl: HOME_BACKGROUND_IMAGE_URL,
+ })
+ .then((dispose) => {
+ if (disposed) {
+ dispose();
+ return;
+ }
+ unmount = dispose;
+ // Wait a frame so the first canvas paint lands before we fade in —
+ // otherwise the transition starts from a blank canvas.
+ readyFrameId = window.requestAnimationFrame(() => {
+ setIsReady(true);
+ });
+ })
+ .catch((error) => {
+ console.error(error);
+ });
+
+ return () => {
+ disposed = true;
+ window.cancelAnimationFrame(readyFrameId);
+ unmount?.();
+ };
+ }, []);
+
+ return ;
+}
diff --git a/packages/twenty-website-new/src/illustrations/HomeStepper/HomeStepperBackgroundIllustration.tsx b/packages/twenty-website-new/src/illustrations/HomeStepper/HomeStepperBackgroundIllustration.tsx
index da2663d0342..e7c7fe85ee7 100644
--- a/packages/twenty-website-new/src/illustrations/HomeStepper/HomeStepperBackgroundIllustration.tsx
+++ b/packages/twenty-website-new/src/illustrations/HomeStepper/HomeStepperBackgroundIllustration.tsx
@@ -2,7 +2,8 @@
import { StepperBackgroundHalftone } from '@/sections/HomeStepper/components/Visual/StepperBackgroundHalftone';
-const HOME_STEPPER_BACKGROUND_IMAGE_URL = '/images/home/stepper/gears.jpg';
+const HOME_STEPPER_BACKGROUND_IMAGE_URL =
+ '/images/home/stepper/download-worker.webp';
export function HomeStepperBackgroundIllustration() {
return (
diff --git a/packages/twenty-website-new/src/illustrations/illustrations-registry.tsx b/packages/twenty-website-new/src/illustrations/illustrations-registry.tsx
index 6880bf0af73..593cbadba1b 100644
--- a/packages/twenty-website-new/src/illustrations/illustrations-registry.tsx
+++ b/packages/twenty-website-new/src/illustrations/illustrations-registry.tsx
@@ -6,6 +6,7 @@ import { FooterBackground } from './Footer/Background';
import { Money } from './Helped/Money';
import { Spaceship } from './Helped/Spaceship';
import { Target } from './Helped/Target';
+import { HomeBackgroundHalftone } from './Hero/HomeBackgroundHalftone';
import { Product } from './Hero/Product';
import { WhyTwenty } from './Hero/WhyTwenty';
import { Quotes } from './Quote/Quotes';
@@ -62,6 +63,7 @@ export const ILLUSTRATIONS = {
...THREE_CARDS_ILLUSTRATIONS,
faqBackground: FaqBackground,
footerBackground: FooterBackground,
+ heroHomeBackground: HomeBackgroundHalftone,
heroPartnerHalftone: PartnerHeroHalftoneIllustration,
homeStepperBackgroundHalftone: HomeStepperBackgroundIllustration,
problemMonolith: ProblemMonolithIllustration,
diff --git a/packages/twenty-website-new/src/sections/Hero/components/Cta/Cta.tsx b/packages/twenty-website-new/src/sections/Hero/components/Cta/Cta.tsx
index d20d98b91a8..2ba4c2cfb27 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/Cta/Cta.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/Cta/Cta.tsx
@@ -5,7 +5,7 @@ import { ReactNode } from 'react';
const CTAsContainer = styled.div`
display: flex;
flex-wrap: wrap;
- gap: ${theme.spacing(4)};
+ gap: ${theme.spacing(3)};
justify-content: center;
`;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/DraggableAppWindow.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/DraggableAppWindow.tsx
new file mode 100644
index 00000000000..1acd1855d44
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/DraggableAppWindow.tsx
@@ -0,0 +1,506 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type PointerEvent as ReactPointerEvent,
+ type ReactNode,
+} from 'react';
+import { theme } from '@/theme';
+import { VISUAL_TOKENS } from '../homeVisualTokens';
+import { useWindowOrder } from '../WindowOrder/WindowOrderProvider';
+import { WINDOW_SHADOWS } from '../windowShadows';
+import { MacWindowBar } from './MacWindowBar';
+
+const WINDOW_ID = 'twenty-app-window';
+const MIN_WIDTH = 640;
+const MIN_HEIGHT = 420;
+const MIN_EDGE_GAP = 0;
+// Initial size cap — the hero scene is 1280×832, so we reuse that ratio to
+// keep the window looking like the Twenty app when it's shrunk to fit.
+const INITIAL_MAX_WIDTH = 1040;
+const INITIAL_ASPECT_RATIO = 1280 / 832;
+// Below this parent width, stack App Window + Terminal with a small diagonal
+// offset so both remain clickable on mobile.
+const MOBILE_PARENT_BREAKPOINT = 640;
+
+type Position = { left: number; top: number };
+type Size = { width: number; height: number };
+
+type DragState = {
+ pointerId: number;
+ originX: number;
+ originY: number;
+ startLeft: number;
+ startTop: number;
+};
+
+type ResizeCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
+
+type ResizeEdge = 'top' | 'right' | 'bottom' | 'left';
+
+type ResizeHandle = ResizeCorner | ResizeEdge;
+
+type ResizeState = {
+ pointerId: number;
+ originX: number;
+ originY: number;
+ startWidth: number;
+ startHeight: number;
+ startLeft: number;
+ startTop: number;
+ handle: ResizeHandle;
+};
+
+const HORIZONTAL_HANDLES: ReadonlySet = new Set([
+ 'top-left',
+ 'top-right',
+ 'bottom-left',
+ 'bottom-right',
+ 'left',
+ 'right',
+]);
+const VERTICAL_HANDLES: ReadonlySet = new Set([
+ 'top-left',
+ 'top-right',
+ 'bottom-left',
+ 'bottom-right',
+ 'top',
+ 'bottom',
+]);
+const LEFT_HANDLES: ReadonlySet = new Set([
+ 'top-left',
+ 'bottom-left',
+ 'left',
+]);
+const TOP_HANDLES: ReadonlySet = new Set([
+ 'top-left',
+ 'top-right',
+ 'top',
+]);
+
+const Shell = styled.div<{
+ $isResizing: boolean;
+ $isReady: boolean;
+ $isActive: boolean;
+}>`
+ background-color: ${VISUAL_TOKENS.background.primary};
+ background-image: ${VISUAL_TOKENS.background.noisy};
+ border: 1px solid ${VISUAL_TOKENS.border.color.medium};
+ border-radius: 20px;
+ box-shadow: ${({ $isActive }) =>
+ $isActive ? WINDOW_SHADOWS.mobileElevated : WINDOW_SHADOWS.mobileResting};
+ display: flex;
+ flex-direction: column;
+ left: 0;
+ opacity: ${({ $isReady }) => ($isReady ? 1 : 0)};
+ overflow: hidden;
+ position: absolute;
+ top: 0;
+ touch-action: none;
+ transition:
+ box-shadow 0.22s ease,
+ opacity 0.1s ease;
+ will-change: transform, width, height;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ box-shadow: ${({ $isActive }) =>
+ $isActive ? WINDOW_SHADOWS.elevated : WINDOW_SHADOWS.resting};
+ }
+`;
+
+const Content = styled.div`
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ width: 100%;
+`;
+
+const ResizeEdgeBase = styled.div`
+ position: absolute;
+ z-index: 4;
+`;
+const ResizeEdgeTop = styled(ResizeEdgeBase)`
+ cursor: ns-resize;
+ height: 6px;
+ left: 40px;
+ right: 40px;
+ top: -3px;
+`;
+const ResizeEdgeBottom = styled(ResizeEdgeBase)`
+ bottom: -3px;
+ cursor: ns-resize;
+ height: 6px;
+ left: 40px;
+ right: 40px;
+`;
+const ResizeEdgeLeft = styled(ResizeEdgeBase)`
+ bottom: 16px;
+ cursor: ew-resize;
+ left: -3px;
+ top: 16px;
+ width: 6px;
+`;
+const ResizeEdgeRight = styled(ResizeEdgeBase)`
+ bottom: 16px;
+ cursor: ew-resize;
+ right: -3px;
+ top: 16px;
+ width: 6px;
+`;
+
+const ResizeCornerBase = styled.div`
+ height: 16px;
+ position: absolute;
+ width: 16px;
+ z-index: 5;
+`;
+const ResizeCornerTopLeft = styled(ResizeCornerBase)`
+ cursor: nwse-resize;
+ left: -4px;
+ top: -4px;
+`;
+const ResizeCornerTopRight = styled(ResizeCornerBase)`
+ cursor: nesw-resize;
+ right: -4px;
+ top: -4px;
+`;
+const ResizeCornerBottomLeft = styled(ResizeCornerBase)`
+ bottom: -4px;
+ cursor: nesw-resize;
+ left: -4px;
+`;
+const ResizeCornerBottomRight = styled(ResizeCornerBase)`
+ bottom: -4px;
+ cursor: nwse-resize;
+ right: -4px;
+`;
+
+type DraggableAppWindowProps = {
+ children: ReactNode;
+};
+
+export const DraggableAppWindow = ({ children }: DraggableAppWindowProps) => {
+ const shellRef = useRef(null);
+ const dragStateRef = useRef(null);
+ const resizeStateRef = useRef(null);
+
+ const [position, setPosition] = useState(null);
+ const [size, setSize] = useState(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const [isResizing, setIsResizing] = useState(false);
+
+ const { activate, zIndex } = useWindowOrder(WINDOW_ID);
+
+ // On mount, size the window to match the hero scene so it occupies the
+ // same visual footprint as before. Stored in state so drag/resize can move
+ // and shrink it freely afterwards.
+ useLayoutEffect(() => {
+ const shell = shellRef.current;
+ const parent = shell?.parentElement as HTMLElement | null;
+ if (!parent) {
+ return;
+ }
+ const parentRect = parent.getBoundingClientRect();
+
+ if (parentRect.width < MOBILE_PARENT_BREAKPOINT) {
+ // Mobile: pin the App Window to the top of the scene. The Terminal sits
+ // tightly on top of it with only a small diagonal offset peeking out.
+ const mobileWidth = Math.min(parentRect.width, 320);
+ const mobileHeight = Math.min(
+ parentRect.height,
+ mobileWidth / INITIAL_ASPECT_RATIO + 100,
+ );
+ setSize({ width: mobileWidth, height: mobileHeight });
+ setPosition({ left: 0, top: 0 });
+ return;
+ }
+
+ // Cap the initial width so the window reads as a macOS app resting inside
+ // the hero rather than filling it edge-to-edge. Height follows the hero's
+ // aspect ratio so the app layout isn't letterboxed.
+ const initialWidth = Math.min(parentRect.width, INITIAL_MAX_WIDTH);
+ const initialHeight = Math.min(
+ parentRect.height,
+ initialWidth / INITIAL_ASPECT_RATIO,
+ );
+
+ setSize({ width: initialWidth, height: initialHeight });
+ setPosition({
+ left: Math.max(0, (parentRect.width - initialWidth) / 2),
+ top: MIN_EDGE_GAP,
+ });
+ }, []);
+
+ const getParentRect = useCallback(() => {
+ const parent = shellRef.current?.parentElement as HTMLElement | null;
+ return parent?.getBoundingClientRect() ?? null;
+ }, []);
+
+ const clampPosition = useCallback(
+ (candidateLeft: number, candidateTop: number, currentSize: Size) => {
+ const parentRect = getParentRect();
+ if (!parentRect) {
+ return { left: candidateLeft, top: candidateTop };
+ }
+ const maxLeft = parentRect.width - currentSize.width - MIN_EDGE_GAP;
+ const maxTop = parentRect.height - currentSize.height - MIN_EDGE_GAP;
+ return {
+ left: Math.min(Math.max(candidateLeft, MIN_EDGE_GAP), maxLeft),
+ top: Math.min(Math.max(candidateTop, MIN_EDGE_GAP), maxTop),
+ };
+ },
+ [getParentRect],
+ );
+
+ const handleDragStart = useCallback(
+ (event: ReactPointerEvent) => {
+ if (event.pointerType === 'mouse' && event.button !== 0) {
+ return;
+ }
+ const target = event.target as HTMLElement | null;
+ if (
+ target &&
+ target.closest('button, a, input, textarea, select, [role="button"]')
+ ) {
+ return;
+ }
+ if (!position) {
+ return;
+ }
+
+ event.preventDefault();
+ activate();
+
+ const shell = shellRef.current;
+ shell?.setPointerCapture?.(event.pointerId);
+ dragStateRef.current = {
+ pointerId: event.pointerId,
+ originX: event.clientX,
+ originY: event.clientY,
+ startLeft: position.left,
+ startTop: position.top,
+ };
+ setIsDragging(true);
+ },
+ [activate, position],
+ );
+
+ useEffect(() => {
+ if (!isDragging) {
+ return undefined;
+ }
+
+ const handleMove = (event: PointerEvent) => {
+ const state = dragStateRef.current;
+ if (!state || state.pointerId !== event.pointerId || !size) {
+ return;
+ }
+ const nextLeft = state.startLeft + (event.clientX - state.originX);
+ const nextTop = state.startTop + (event.clientY - state.originY);
+ setPosition(clampPosition(nextLeft, nextTop, size));
+ };
+
+ const stop = (event: PointerEvent) => {
+ const state = dragStateRef.current;
+ if (!state || state.pointerId !== event.pointerId) {
+ return;
+ }
+ dragStateRef.current = null;
+ setIsDragging(false);
+ shellRef.current?.releasePointerCapture?.(event.pointerId);
+ };
+
+ window.addEventListener('pointermove', handleMove);
+ window.addEventListener('pointerup', stop);
+ window.addEventListener('pointercancel', stop);
+
+ return () => {
+ window.removeEventListener('pointermove', handleMove);
+ window.removeEventListener('pointerup', stop);
+ window.removeEventListener('pointercancel', stop);
+ };
+ }, [clampPosition, isDragging, size]);
+
+ const startResize = useCallback(
+ (handle: ResizeHandle) => (event: ReactPointerEvent) => {
+ if (event.pointerType === 'mouse' && event.button !== 0) {
+ return;
+ }
+ if (!position || !size) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ activate();
+
+ const shell = shellRef.current;
+ shell?.setPointerCapture?.(event.pointerId);
+ resizeStateRef.current = {
+ pointerId: event.pointerId,
+ originX: event.clientX,
+ originY: event.clientY,
+ startWidth: size.width,
+ startHeight: size.height,
+ startLeft: position.left,
+ startTop: position.top,
+ handle,
+ };
+ setIsResizing(true);
+ },
+ [activate, position, size],
+ );
+
+ useEffect(() => {
+ if (!isResizing) {
+ return undefined;
+ }
+
+ const handleMove = (event: PointerEvent) => {
+ const state = resizeStateRef.current;
+ if (!state || state.pointerId !== event.pointerId) {
+ return;
+ }
+ const parentRect = getParentRect();
+ if (!parentRect) {
+ return;
+ }
+
+ const deltaX = event.clientX - state.originX;
+ const deltaY = event.clientY - state.originY;
+
+ const affectsWidth = HORIZONTAL_HANDLES.has(state.handle);
+ const affectsHeight = VERTICAL_HANDLES.has(state.handle);
+ const growsFromLeft = LEFT_HANDLES.has(state.handle);
+ const growsFromTop = TOP_HANDLES.has(state.handle);
+
+ // Mobile parents can be narrower than MIN_WIDTH; clamp the min against
+ // the parent so resize can't demand more room than exists.
+ const effectiveMinWidth = Math.min(
+ MIN_WIDTH,
+ Math.max(parentRect.width - MIN_EDGE_GAP * 2, 0),
+ );
+ const effectiveMinHeight = Math.min(
+ MIN_HEIGHT,
+ Math.max(parentRect.height - MIN_EDGE_GAP * 2, 0),
+ );
+
+ let nextWidth = state.startWidth;
+ let nextLeft = state.startLeft;
+ if (affectsWidth) {
+ if (growsFromLeft) {
+ // Left edge can't cross past MIN_EDGE_GAP, which caps width at
+ // startLeft + startWidth - MIN_EDGE_GAP.
+ const maxWidth = state.startWidth + state.startLeft - MIN_EDGE_GAP;
+ nextWidth = Math.min(
+ Math.max(state.startWidth - deltaX, effectiveMinWidth),
+ Math.max(maxWidth, effectiveMinWidth),
+ );
+ nextLeft = state.startLeft + state.startWidth - nextWidth;
+ } else {
+ const maxWidth = parentRect.width - state.startLeft - MIN_EDGE_GAP;
+ nextWidth = Math.min(
+ Math.max(state.startWidth + deltaX, effectiveMinWidth),
+ Math.max(maxWidth, effectiveMinWidth),
+ );
+ }
+ }
+
+ let nextHeight = state.startHeight;
+ let nextTop = state.startTop;
+ if (affectsHeight) {
+ if (growsFromTop) {
+ const maxHeight = state.startHeight + state.startTop - MIN_EDGE_GAP;
+ nextHeight = Math.min(
+ Math.max(state.startHeight - deltaY, effectiveMinHeight),
+ Math.max(maxHeight, effectiveMinHeight),
+ );
+ nextTop = state.startTop + state.startHeight - nextHeight;
+ } else {
+ const maxHeight = parentRect.height - state.startTop - MIN_EDGE_GAP;
+ nextHeight = Math.min(
+ Math.max(state.startHeight + deltaY, effectiveMinHeight),
+ Math.max(maxHeight, effectiveMinHeight),
+ );
+ }
+ }
+
+ setSize({ width: nextWidth, height: nextHeight });
+ setPosition({ left: nextLeft, top: nextTop });
+ };
+
+ const stop = (event: PointerEvent) => {
+ const state = resizeStateRef.current;
+ if (!state || state.pointerId !== event.pointerId) {
+ return;
+ }
+ resizeStateRef.current = null;
+ setIsResizing(false);
+ shellRef.current?.releasePointerCapture?.(event.pointerId);
+ };
+
+ window.addEventListener('pointermove', handleMove);
+ window.addEventListener('pointerup', stop);
+ window.addEventListener('pointercancel', stop);
+
+ return () => {
+ window.removeEventListener('pointermove', handleMove);
+ window.removeEventListener('pointerup', stop);
+ window.removeEventListener('pointercancel', stop);
+ };
+ }, [getParentRect, isResizing]);
+
+ // Any pointer-down on the window activates it; the MacWindowBar owns the
+ // drag affordance separately so content clicks don't pull the window.
+ const handleShellPointerDown = useCallback(() => {
+ activate();
+ }, [activate]);
+
+ const isReady = position !== null && size !== null;
+
+ return (
+ 2}
+ $isReady={isReady}
+ $isResizing={isResizing}
+ onPointerDown={handleShellPointerDown}
+ ref={shellRef}
+ style={{
+ height: size ? `${size.height}px` : undefined,
+ transform: position
+ ? `translate(${position.left}px, ${position.top}px)`
+ : 'translate(0, 0)',
+ width: size ? `${size.width}px` : '100%',
+ zIndex,
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ {children}
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/MacWindowBar.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/MacWindowBar.tsx
new file mode 100644
index 00000000000..54ca003b8bc
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/MacWindowBar.tsx
@@ -0,0 +1,61 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import type { PointerEvent as ReactPointerEvent } from 'react';
+import { TerminalTrafficLights } from '../DraggableTerminal/TerminalTrafficLights';
+
+type MacWindowBarProps = {
+ title?: string;
+ onDragStart: (event: ReactPointerEvent) => void;
+ isDragging: boolean;
+};
+
+const BAR_BACKGROUND = '#F7F7F7';
+const BAR_BORDER = 'rgba(0, 0, 0, 0.05)';
+const TITLE_COLOR = 'rgba(40, 36, 30, 0.62)';
+const BAR_VERTICAL_PADDING = 8;
+const BAR_HORIZONTAL_PADDING = 12;
+const TRAFFIC_LIGHT_WIDTH = 52;
+
+const BarRoot = styled.div<{ $isDragging: boolean }>`
+ align-items: center;
+ background: ${BAR_BACKGROUND};
+ border-bottom: 1px solid ${BAR_BORDER};
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
+ cursor: ${({ $isDragging }) => ($isDragging ? 'grabbing' : 'grab')};
+ display: grid;
+ flex-shrink: 0;
+ grid-template-columns: auto 1fr auto;
+ padding: ${BAR_VERTICAL_PADDING}px ${BAR_HORIZONTAL_PADDING}px;
+ user-select: none;
+ width: 100%;
+`;
+
+const Title = styled.span`
+ color: ${TITLE_COLOR};
+ font-family: 'Inter', sans-serif;
+ font-size: 12px;
+ font-weight: 500;
+ justify-self: center;
+ letter-spacing: 0.1px;
+ text-align: center;
+`;
+
+const RightSpacer = styled.div`
+ // Mirrors the Mac bar controls width so the centered title does not drift.
+ width: ${TRAFFIC_LIGHT_WIDTH}px;
+`;
+
+export const MacWindowBar = ({
+ title = 'Twenty',
+ onDragStart,
+ isDragging,
+}: MacWindowBarProps) => {
+ return (
+
+
+ {title}
+
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/ClaudeLogo.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/ClaudeLogo.tsx
new file mode 100644
index 00000000000..a9eb4c0743c
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/ClaudeLogo.tsx
@@ -0,0 +1,17 @@
+'use client';
+
+import Image from 'next/image';
+
+export const ClaudeLogo = ({ size = 14 }: { size?: number }) => {
+ return (
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/CursorLogo.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/CursorLogo.tsx
new file mode 100644
index 00000000000..9a57d22de86
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/CursorLogo.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+// Cursor app icon — black rounded tile wrapping an isometric hexagonal prism
+// with a folded white arrow bursting out of its front face.
+export const CursorLogo = ({ size = 14 }: { size?: number }) => {
+ return (
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/DraggableTerminal.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/DraggableTerminal.tsx
new file mode 100644
index 00000000000..6c9af8f9f0a
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/DraggableTerminal.tsx
@@ -0,0 +1,840 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type PointerEvent as ReactPointerEvent,
+} from 'react';
+import { theme } from '@/theme';
+import { useWindowOrder } from '../WindowOrder/WindowOrderProvider';
+import { WINDOW_SHADOWS } from '../windowShadows';
+import {
+ ConversationPanel,
+ type ConversationMessage,
+} from './conversation/ConversationPanel';
+import { TerminalDiff } from './TerminalDiff/TerminalDiff';
+import { EDITOR_TOKENS } from './TerminalEditor/editorTokens';
+import { TerminalEditor } from './TerminalEditor/TerminalEditor';
+import { TerminalPromptBox } from './TerminalPromptBox';
+import { TerminalTopBar } from './TerminalTopBar';
+import { type TerminalToggleValue } from './TerminalToggle';
+import { TERMINAL_TOKENS } from './terminalTokens';
+
+const WINDOW_ID = 'terminal-window';
+const INITIAL_PROMPT_TEXT =
+ 'Scaffold a launch-ops CRM in my workspace with rockets, launches, payloads, customers, and launch sites, with relevant actions for each.';
+const CLEARED_PROMPT_TEXT = 'Ask anything…';
+
+// Initial / minimum dimensions for the Terminal window (Figma mock).
+// Initial height is tuned to hug the prompt box + top bar so there's no
+// large empty area above the prompt before the chat runs — it expands to
+// TERMINAL_CHAT_EXPANDED_HEIGHT once the conversation starts.
+const TERMINAL_INITIAL_WIDTH = 380;
+const TERMINAL_INITIAL_HEIGHT = 220;
+const TERMINAL_CHAT_EXPANDED_HEIGHT = 480;
+const TERMINAL_EDITOR_WIDTH = 720;
+const TERMINAL_EDITOR_HEIGHT = 480;
+const TERMINAL_MIN_WIDTH = 300;
+const TERMINAL_MIN_HEIGHT = 200;
+const TERMINAL_INITIAL_BOTTOM_OFFSET = 96;
+const MIN_EDGE_GAP = 0;
+// Below this parent width, stack Terminal below App Window with a diagonal
+// offset so both windows stay visible and clickable on mobile.
+const MOBILE_PARENT_BREAKPOINT = 640;
+const MOBILE_OFFSET_X = 16;
+const MOBILE_OFFSET_Y = 48;
+
+type ResizeCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
+
+type ResizeEdge = 'top' | 'right' | 'bottom' | 'left';
+
+type ResizeHandle = ResizeCorner | ResizeEdge;
+
+type DragState = {
+ pointerId: number;
+ originX: number;
+ originY: number;
+ startLeft: number;
+ startTop: number;
+};
+
+type ResizeState = {
+ pointerId: number;
+ originX: number;
+ originY: number;
+ startWidth: number;
+ startHeight: number;
+ startLeft: number;
+ startTop: number;
+ handle: ResizeHandle;
+};
+
+type TerminalPosition = { left: number; top: number };
+type TerminalSize = { width: number; height: number };
+
+const HORIZONTAL_HANDLES: ReadonlySet = new Set([
+ 'top-left',
+ 'top-right',
+ 'bottom-left',
+ 'bottom-right',
+ 'left',
+ 'right',
+]);
+const VERTICAL_HANDLES: ReadonlySet = new Set([
+ 'top-left',
+ 'top-right',
+ 'bottom-left',
+ 'bottom-right',
+ 'top',
+ 'bottom',
+]);
+const LEFT_HANDLES: ReadonlySet = new Set([
+ 'top-left',
+ 'bottom-left',
+ 'left',
+]);
+const TOP_HANDLES: ReadonlySet = new Set([
+ 'top-left',
+ 'top-right',
+ 'top',
+]);
+
+const Shell = styled.div<{
+ $isDragging: boolean;
+ $isResizing: boolean;
+ $isReady: boolean;
+ $animationsEnabled: boolean;
+ $dark: boolean;
+}>`
+ background: ${({ $dark }) =>
+ $dark ? EDITOR_TOKENS.surface.body : TERMINAL_TOKENS.surface.window};
+ border: 1px solid ${TERMINAL_TOKENS.surface.windowBorder};
+ border-radius: 20px;
+ box-shadow: ${({ $isDragging, $isResizing }) =>
+ $isDragging || $isResizing
+ ? WINDOW_SHADOWS.mobileElevated
+ : WINDOW_SHADOWS.mobileResting};
+ display: flex;
+ flex-direction: column;
+ left: 0;
+ opacity: ${({ $isReady }) => ($isReady ? 1 : 0)};
+ overflow: hidden;
+ position: absolute;
+ top: 0;
+ touch-action: none;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ box-shadow: ${({ $isDragging, $isResizing }) =>
+ $isDragging || $isResizing
+ ? WINDOW_SHADOWS.elevated
+ : WINDOW_SHADOWS.resting};
+ }
+ transition: ${({ $isDragging, $isResizing, $animationsEnabled }) => {
+ if ($isDragging || $isResizing) {
+ return 'background-color 0.22s ease, box-shadow 0.14s ease, opacity 0.1s ease';
+ }
+ const base =
+ 'background-color 0.22s ease, box-shadow 0.22s ease, opacity 0.1s ease';
+ if (!$animationsEnabled) {
+ return base;
+ }
+ // Spring-like curve (overshoots slightly then settles) for a lively grow.
+ const springCurve = 'cubic-bezier(0.34, 1.45, 0.55, 1)';
+ const growDuration = '0.42s';
+ return `${base}, height ${growDuration} ${springCurve}, width ${growDuration} ${springCurve}, transform ${growDuration} ${springCurve}`;
+ }};
+ will-change: transform, width, height;
+`;
+
+const Body = styled.div`
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ justify-content: flex-end;
+ min-height: 0;
+ position: relative;
+ width: 100%;
+`;
+
+const ViewLayer = styled.div<{ $visible: boolean; $row?: boolean }>`
+ display: flex;
+ flex-direction: ${({ $row }) => ($row ? 'row' : 'column')};
+ inset: 0;
+ justify-content: ${({ $row }) => ($row ? 'flex-start' : 'flex-end')};
+ opacity: ${({ $visible }) => ($visible ? 1 : 0)};
+ pointer-events: ${({ $visible }) => ($visible ? 'auto' : 'none')};
+ position: absolute;
+ transition: opacity 220ms ease;
+`;
+
+const ChatColumn = styled.div`
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ justify-content: flex-end;
+ min-height: 0;
+ min-width: 0;
+`;
+
+const DiffSlide = styled.div<{ $open: boolean }>`
+ display: flex;
+ flex: 0 0 ${({ $open }) => ($open ? '55%' : '0')};
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ transition: flex-basis 320ms cubic-bezier(0.22, 1, 0.36, 1);
+ width: ${({ $open }) => ($open ? '55%' : '0')};
+`;
+
+const ResizeCornerBase = styled.div`
+ height: 16px;
+ position: absolute;
+ width: 16px;
+ z-index: 5;
+
+ &::after {
+ border-radius: 1px;
+ content: '';
+ height: 8px;
+ opacity: 0;
+ position: absolute;
+ transition: opacity 0.18s ease;
+ width: 8px;
+ }
+
+ &:hover::after {
+ opacity: 1;
+ }
+`;
+
+const ResizeCornerTopLeft = styled(ResizeCornerBase)`
+ cursor: nwse-resize;
+ left: -4px;
+ top: -4px;
+
+ &::after {
+ border-left: 2px solid rgba(0, 0, 0, 0.18);
+ border-top: 2px solid rgba(0, 0, 0, 0.18);
+ left: 6px;
+ top: 6px;
+ }
+`;
+
+const ResizeCornerTopRight = styled(ResizeCornerBase)`
+ cursor: nesw-resize;
+ right: -4px;
+ top: -4px;
+
+ &::after {
+ border-right: 2px solid rgba(0, 0, 0, 0.18);
+ border-top: 2px solid rgba(0, 0, 0, 0.18);
+ right: 6px;
+ top: 6px;
+ }
+`;
+
+const ResizeCornerBottomLeft = styled(ResizeCornerBase)`
+ bottom: -4px;
+ cursor: nesw-resize;
+ left: -4px;
+
+ &::after {
+ border-bottom: 2px solid rgba(0, 0, 0, 0.18);
+ border-left: 2px solid rgba(0, 0, 0, 0.18);
+ bottom: 6px;
+ left: 6px;
+ }
+`;
+
+const ResizeCornerBottomRight = styled(ResizeCornerBase)`
+ bottom: -4px;
+ cursor: nwse-resize;
+ right: -4px;
+
+ &::after {
+ border-bottom: 2px solid rgba(0, 0, 0, 0.18);
+ border-right: 2px solid rgba(0, 0, 0, 0.18);
+ bottom: 6px;
+ right: 6px;
+ }
+`;
+
+const ResizeEdgeBase = styled.div`
+ position: absolute;
+ z-index: 4;
+`;
+
+const ResizeEdgeTop = styled(ResizeEdgeBase)`
+ cursor: ns-resize;
+ height: 6px;
+ left: 12px;
+ right: 12px;
+ top: -3px;
+`;
+
+const ResizeEdgeBottom = styled(ResizeEdgeBase)`
+ bottom: -3px;
+ cursor: ns-resize;
+ height: 6px;
+ left: 12px;
+ right: 12px;
+`;
+
+const ResizeEdgeLeft = styled(ResizeEdgeBase)`
+ bottom: 12px;
+ cursor: ew-resize;
+ left: -3px;
+ top: 12px;
+ width: 6px;
+`;
+
+const ResizeEdgeRight = styled(ResizeEdgeBase)`
+ bottom: 12px;
+ cursor: ew-resize;
+ right: -3px;
+ top: 12px;
+ width: 6px;
+`;
+
+type DraggableTerminalProps = {
+ onObjectCreated?: (id: string) => void;
+ onChatFinished?: () => void;
+ onChatReset?: () => void;
+ onJumpToConversationEnd?: () => void;
+};
+
+export const DraggableTerminal = ({
+ onObjectCreated,
+ onChatFinished,
+ onChatReset,
+ onJumpToConversationEnd,
+}: DraggableTerminalProps) => {
+ const shellRef = useRef(null);
+ const dragStateRef = useRef(null);
+ const resizeStateRef = useRef(null);
+ const hasAnnouncedChatFinishedRef = useRef(false);
+
+ const [position, setPosition] = useState(null);
+ const [size, setSize] = useState({
+ width: TERMINAL_INITIAL_WIDTH,
+ height: TERMINAL_INITIAL_HEIGHT,
+ });
+ const [isDragging, setIsDragging] = useState(false);
+ const [isResizing, setIsResizing] = useState(false);
+ const [messages, setMessages] = useState([]);
+ const [view, setView] = useState('ai-chat');
+ const [isChatFinished, setIsChatFinished] = useState(false);
+ const [isDiffOpen, setIsDiffOpen] = useState(false);
+ const [animationsEnabled, setAnimationsEnabled] = useState(false);
+ const [instantComplete, setInstantComplete] = useState(false);
+
+ useEffect(() => {
+ // Defer enabling grow animations until after the initial fade-in so the
+ // first paint positions the window without animating from translate(0,0).
+ const timeoutId = window.setTimeout(() => {
+ setAnimationsEnabled(true);
+ }, 150);
+ return () => window.clearTimeout(timeoutId);
+ }, []);
+
+ const { activate, zIndex } = useWindowOrder(WINDOW_ID);
+
+ const hasStartedConversation = messages.length > 0;
+
+ const resizeAnchored = useCallback(
+ (targetWidth: number, targetHeight: number) => {
+ if (size.height === targetHeight && size.width === targetWidth) {
+ return;
+ }
+ const deltaX = size.width - targetWidth;
+ const deltaY = size.height - targetHeight;
+ const parentRect =
+ shellRef.current?.parentElement?.getBoundingClientRect() ?? null;
+ setSize({ width: targetWidth, height: targetHeight });
+ // Keep whichever corner is closest to the parent edges anchored so the
+ // window grows outward from the corner nearest the page boundary.
+ setPosition((pos) => {
+ if (!pos) {
+ return pos;
+ }
+ const parentWidth = parentRect?.width ?? size.width;
+ const parentHeight = parentRect?.height ?? size.height;
+ const centerX = pos.left + size.width / 2;
+ const centerY = pos.top + size.height / 2;
+ const anchorRight = centerX > parentWidth / 2;
+ const anchorBottom = centerY > parentHeight / 2;
+ return {
+ left: anchorRight ? pos.left + deltaX : pos.left,
+ top: anchorBottom ? pos.top + deltaY : pos.top,
+ };
+ });
+ },
+ [size],
+ );
+
+ const getTargetDimensions = useCallback(
+ (nextView: TerminalToggleValue, chatStarted: boolean) => {
+ const parentRect =
+ shellRef.current?.parentElement?.getBoundingClientRect() ?? null;
+ const isMobileParent =
+ parentRect !== null && parentRect.width < MOBILE_PARENT_BREAKPOINT;
+ const maxWidth = parentRect
+ ? parentRect.width - (isMobileParent ? MOBILE_OFFSET_X : 0)
+ : Infinity;
+ const maxHeight = parentRect
+ ? parentRect.height - (isMobileParent ? MOBILE_OFFSET_Y : 0)
+ : Infinity;
+ if (nextView === 'editor') {
+ return {
+ width: Math.min(TERMINAL_EDITOR_WIDTH, maxWidth),
+ height: Math.min(TERMINAL_EDITOR_HEIGHT, maxHeight),
+ };
+ }
+ return {
+ width: Math.min(TERMINAL_INITIAL_WIDTH, maxWidth),
+ height: Math.min(
+ chatStarted ? TERMINAL_CHAT_EXPANDED_HEIGHT : TERMINAL_INITIAL_HEIGHT,
+ maxHeight,
+ ),
+ };
+ },
+ [],
+ );
+
+ const handleSendPrompt = useCallback(() => {
+ if (hasStartedConversation) {
+ return;
+ }
+ setInstantComplete(false);
+ const sendAt = Date.now();
+ setMessages([
+ { id: `u-${sendAt}`, role: 'user', text: INITIAL_PROMPT_TEXT },
+ { id: `a-${sendAt}`, role: 'assistant' },
+ ]);
+ if (view === 'ai-chat') {
+ const { width, height } = getTargetDimensions('ai-chat', true);
+ resizeAnchored(width, height);
+ }
+ }, [getTargetDimensions, hasStartedConversation, resizeAnchored, view]);
+
+ const handleViewChange = useCallback(
+ (next: TerminalToggleValue) => {
+ setView(next);
+ const { width, height } = getTargetDimensions(
+ next,
+ hasStartedConversation,
+ );
+ resizeAnchored(width, height);
+ },
+ [getTargetDimensions, hasStartedConversation, resizeAnchored],
+ );
+
+ const handleResetConversation = useCallback(() => {
+ hasAnnouncedChatFinishedRef.current = false;
+ setMessages([]);
+ setIsChatFinished(false);
+ setIsDiffOpen(false);
+ setInstantComplete(false);
+ setView('ai-chat');
+ const { width, height } = getTargetDimensions('ai-chat', false);
+ resizeAnchored(width, height);
+ onChatReset?.();
+ }, [getTargetDimensions, onChatReset, resizeAnchored]);
+
+ const handleToggleDiff = useCallback(() => {
+ setIsDiffOpen((current) => !current);
+ }, []);
+
+ const handleChatFinishedInternal = useCallback(() => {
+ setIsChatFinished(true);
+ if (hasAnnouncedChatFinishedRef.current) {
+ return;
+ }
+ hasAnnouncedChatFinishedRef.current = true;
+ onChatFinished?.();
+ }, [onChatFinished]);
+
+ const handleJumpToConversationEnd = useCallback(() => {
+ if (!hasStartedConversation) {
+ const sendAt = Date.now();
+ setMessages([
+ { id: `u-${sendAt}`, role: 'user', text: INITIAL_PROMPT_TEXT },
+ { id: `a-${sendAt}`, role: 'assistant' },
+ ]);
+ }
+ setInstantComplete(true);
+ setIsDiffOpen(false);
+ setView('ai-chat');
+ const { width, height } = getTargetDimensions('ai-chat', true);
+ resizeAnchored(width, height);
+ onJumpToConversationEnd?.();
+ handleChatFinishedInternal();
+ }, [
+ getTargetDimensions,
+ handleChatFinishedInternal,
+ hasStartedConversation,
+ onJumpToConversationEnd,
+ resizeAnchored,
+ ]);
+
+ // On mount, measure the hero scene and anchor the window to the
+ // bottom-right corner. Done in a layout effect so we paint into position
+ // without a visible flicker (Shell starts with opacity 0 until ready).
+ useLayoutEffect(() => {
+ const shell = shellRef.current;
+ const parent = shell?.parentElement as HTMLElement | null;
+ if (!shell || !parent) {
+ return;
+ }
+
+ const parentRect = parent.getBoundingClientRect();
+
+ if (parentRect.width < MOBILE_PARENT_BREAKPOINT) {
+ // Mobile: stack Terminal on top of App Window with a small diagonal
+ // offset so the App Window corner stays peeking out and tappable.
+ const mobileWidth = Math.min(
+ TERMINAL_INITIAL_WIDTH,
+ parentRect.width - MOBILE_OFFSET_X,
+ );
+ const mobileHeight = Math.min(
+ TERMINAL_INITIAL_HEIGHT,
+ parentRect.height - MOBILE_OFFSET_Y,
+ );
+ setSize({ width: mobileWidth, height: mobileHeight });
+ setPosition({
+ left: MOBILE_OFFSET_X,
+ top: MOBILE_OFFSET_Y,
+ });
+ return;
+ }
+
+ const initialWidth = Math.min(TERMINAL_INITIAL_WIDTH, parentRect.width);
+ const initialHeight = Math.min(TERMINAL_INITIAL_HEIGHT, parentRect.height);
+
+ setSize({ width: initialWidth, height: initialHeight });
+ setPosition({
+ left: Math.max(0, parentRect.width - initialWidth),
+ top: Math.max(
+ 0,
+ parentRect.height - initialHeight - TERMINAL_INITIAL_BOTTOM_OFFSET,
+ ),
+ });
+ }, []);
+
+ const getParentRect = useCallback(() => {
+ const shell = shellRef.current;
+ const parent = shell?.parentElement as HTMLElement | null;
+ return parent?.getBoundingClientRect() ?? null;
+ }, []);
+
+ const clampPosition = useCallback(
+ (
+ candidateLeft: number,
+ candidateTop: number,
+ currentSize: TerminalSize,
+ ) => {
+ const parentRect = getParentRect();
+ if (!parentRect) {
+ return { left: candidateLeft, top: candidateTop };
+ }
+
+ const maxLeft = parentRect.width - currentSize.width - MIN_EDGE_GAP;
+ const maxTop = parentRect.height - currentSize.height - MIN_EDGE_GAP;
+
+ return {
+ left: Math.min(Math.max(candidateLeft, MIN_EDGE_GAP), maxLeft),
+ top: Math.min(Math.max(candidateTop, MIN_EDGE_GAP), maxTop),
+ };
+ },
+ [getParentRect],
+ );
+
+ // Drag handling.
+ const handleDragStart = useCallback(
+ (event: ReactPointerEvent) => {
+ if (event.pointerType === 'mouse' && event.button !== 0) {
+ return;
+ }
+
+ const target = event.target as HTMLElement | null;
+ if (target && target.closest('button')) {
+ return;
+ }
+
+ event.preventDefault();
+ activate();
+
+ const shell = shellRef.current;
+ if (!shell || !position) {
+ return;
+ }
+
+ shell.setPointerCapture?.(event.pointerId);
+ dragStateRef.current = {
+ pointerId: event.pointerId,
+ originX: event.clientX,
+ originY: event.clientY,
+ startLeft: position.left,
+ startTop: position.top,
+ };
+ setIsDragging(true);
+ },
+ [activate, position],
+ );
+
+ useEffect(() => {
+ if (!isDragging) {
+ return undefined;
+ }
+
+ const handleMove = (event: PointerEvent) => {
+ const state = dragStateRef.current;
+ if (!state || state.pointerId !== event.pointerId) {
+ return;
+ }
+
+ const nextLeft = state.startLeft + (event.clientX - state.originX);
+ const nextTop = state.startTop + (event.clientY - state.originY);
+ setPosition(clampPosition(nextLeft, nextTop, size));
+ };
+
+ const stopDragging = (event: PointerEvent) => {
+ const state = dragStateRef.current;
+ if (!state || state.pointerId !== event.pointerId) {
+ return;
+ }
+ dragStateRef.current = null;
+ setIsDragging(false);
+ shellRef.current?.releasePointerCapture?.(event.pointerId);
+ };
+
+ window.addEventListener('pointermove', handleMove);
+ window.addEventListener('pointerup', stopDragging);
+ window.addEventListener('pointercancel', stopDragging);
+
+ return () => {
+ window.removeEventListener('pointermove', handleMove);
+ window.removeEventListener('pointerup', stopDragging);
+ window.removeEventListener('pointercancel', stopDragging);
+ };
+ }, [clampPosition, isDragging, size]);
+
+ // Resize handling.
+ const startResize = useCallback(
+ (handle: ResizeHandle) => (event: ReactPointerEvent) => {
+ if (event.pointerType === 'mouse' && event.button !== 0) {
+ return;
+ }
+ if (!position) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ activate();
+
+ const shell = shellRef.current;
+ if (!shell) {
+ return;
+ }
+
+ shell.setPointerCapture?.(event.pointerId);
+ resizeStateRef.current = {
+ pointerId: event.pointerId,
+ originX: event.clientX,
+ originY: event.clientY,
+ startWidth: size.width,
+ startHeight: size.height,
+ startLeft: position.left,
+ startTop: position.top,
+ handle,
+ };
+ setIsResizing(true);
+ },
+ [activate, position, size],
+ );
+
+ useEffect(() => {
+ if (!isResizing) {
+ return undefined;
+ }
+
+ const handleMove = (event: PointerEvent) => {
+ const state = resizeStateRef.current;
+ if (!state || state.pointerId !== event.pointerId) {
+ return;
+ }
+
+ const parentRect = getParentRect();
+ if (!parentRect) {
+ return;
+ }
+
+ const deltaX = event.clientX - state.originX;
+ const deltaY = event.clientY - state.originY;
+
+ const affectsWidth = HORIZONTAL_HANDLES.has(state.handle);
+ const affectsHeight = VERTICAL_HANDLES.has(state.handle);
+ const growsFromLeft = LEFT_HANDLES.has(state.handle);
+ const growsFromTop = TOP_HANDLES.has(state.handle);
+
+ const effectiveMinWidth = Math.min(
+ TERMINAL_MIN_WIDTH,
+ Math.max(parentRect.width - MIN_EDGE_GAP * 2, 0),
+ );
+ const effectiveMinHeight = Math.min(
+ TERMINAL_MIN_HEIGHT,
+ Math.max(parentRect.height - MIN_EDGE_GAP * 2, 0),
+ );
+
+ let nextWidth = state.startWidth;
+ let nextLeft = state.startLeft;
+ if (affectsWidth) {
+ if (growsFromLeft) {
+ const maxWidth = state.startWidth + state.startLeft - MIN_EDGE_GAP;
+ nextWidth = Math.min(
+ Math.max(state.startWidth - deltaX, effectiveMinWidth),
+ Math.max(maxWidth, effectiveMinWidth),
+ );
+ nextLeft = state.startLeft + state.startWidth - nextWidth;
+ } else {
+ const maxWidth = parentRect.width - state.startLeft - MIN_EDGE_GAP;
+ nextWidth = Math.min(
+ Math.max(state.startWidth + deltaX, effectiveMinWidth),
+ Math.max(maxWidth, effectiveMinWidth),
+ );
+ }
+ }
+
+ let nextHeight = state.startHeight;
+ let nextTop = state.startTop;
+ if (affectsHeight) {
+ if (growsFromTop) {
+ const maxHeight = state.startHeight + state.startTop - MIN_EDGE_GAP;
+ nextHeight = Math.min(
+ Math.max(state.startHeight - deltaY, effectiveMinHeight),
+ Math.max(maxHeight, effectiveMinHeight),
+ );
+ nextTop = state.startTop + state.startHeight - nextHeight;
+ } else {
+ const maxHeight = parentRect.height - state.startTop - MIN_EDGE_GAP;
+ nextHeight = Math.min(
+ Math.max(state.startHeight + deltaY, effectiveMinHeight),
+ Math.max(maxHeight, effectiveMinHeight),
+ );
+ }
+ }
+
+ setSize({ width: nextWidth, height: nextHeight });
+ setPosition({ left: nextLeft, top: nextTop });
+ };
+
+ const stopResizing = (event: PointerEvent) => {
+ const state = resizeStateRef.current;
+ if (!state || state.pointerId !== event.pointerId) {
+ return;
+ }
+ resizeStateRef.current = null;
+ setIsResizing(false);
+ shellRef.current?.releasePointerCapture?.(event.pointerId);
+ };
+
+ window.addEventListener('pointermove', handleMove);
+ window.addEventListener('pointerup', stopResizing);
+ window.addEventListener('pointercancel', stopResizing);
+
+ return () => {
+ window.removeEventListener('pointermove', handleMove);
+ window.removeEventListener('pointerup', stopResizing);
+ window.removeEventListener('pointercancel', stopResizing);
+ };
+ }, [getParentRect, isResizing]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {hasStartedConversation ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/DiffPill.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/DiffPill.tsx
new file mode 100644
index 00000000000..0eba8ac2847
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/DiffPill.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import { TERMINAL_TOKENS } from '../terminalTokens';
+
+type DiffPillProps = {
+ added: number;
+ removed: number;
+ isActive?: boolean;
+ onClick?: () => void;
+};
+
+// Single button styled like a segmented-control segment. Reads as plain
+// monospace text by default; on hover (or when diff panel is open) it shows
+// a subtle background so it feels like the TerminalToggle's active segment.
+// Height (30px) matches the TerminalToggle outer shell so both controls
+// align along the top bar baseline.
+const PillButton = styled.button<{ $active?: boolean }>`
+ align-items: center;
+ background: ${({ $active }) =>
+ $active ? TERMINAL_TOKENS.surface.activeSegmentBackground : 'transparent'};
+ border: 1px solid
+ ${({ $active }) =>
+ $active ? TERMINAL_TOKENS.surface.activeSegmentBorder : 'transparent'};
+ border-radius: 6px;
+ box-shadow: ${({ $active }) =>
+ $active ? TERMINAL_TOKENS.shadow.activeSegment : 'none'};
+ box-sizing: border-box;
+ cursor: pointer;
+ display: inline-flex;
+ font-family: ${TERMINAL_TOKENS.font.mono};
+ font-size: 13px;
+ font-weight: 500;
+ gap: 4px;
+ height: 30px;
+ line-height: 1;
+ padding: 0 8px;
+ transition:
+ background-color 0.2s ease,
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+ white-space: nowrap;
+
+ &:hover {
+ background: ${({ $active }) =>
+ $active
+ ? TERMINAL_TOKENS.surface.activeSegmentBackground
+ : TERMINAL_TOKENS.surface.inactiveSegmentHoverBackground};
+ border-color: ${({ $active }) =>
+ $active ? TERMINAL_TOKENS.surface.activeSegmentBorder : 'transparent'};
+ }
+`;
+
+const Added = styled.span`
+ color: #377e5d;
+`;
+
+const Removed = styled.span`
+ color: #a94a4f;
+`;
+
+export const DiffPill = ({
+ added,
+ removed,
+ isActive,
+ onClick,
+}: DiffPillProps) => {
+ return (
+
+ +{added}
+ -{removed}
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/TerminalDiff.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/TerminalDiff.tsx
new file mode 100644
index 00000000000..1cf54763133
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/TerminalDiff.tsx
@@ -0,0 +1,212 @@
+'use client';
+
+import { IconArrowsVertical } from '@tabler/icons-react';
+import { styled } from '@linaria/react';
+import { TERMINAL_TOKENS } from '../terminalTokens';
+import {
+ DIFF_FILES,
+ type DiffChunk,
+ type DiffFile,
+ type DiffToken,
+ type DiffTokenKind,
+} from './diffData';
+
+const Root = styled.div`
+ background: rgba(0, 0, 0, 0.02);
+ border-left: 1px solid rgba(0, 0, 0, 0.08);
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ min-width: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+
+ &::-webkit-scrollbar {
+ width: 8px;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: rgba(0, 0, 0, 0.1);
+ border-radius: 999px;
+ }
+`;
+
+const FileHeader = styled.div`
+ align-items: center;
+ background: rgba(0, 0, 0, 0.02);
+ border-bottom: 1px solid rgba(0, 0, 0, 0.04);
+ display: flex;
+ flex: 0 0 auto;
+ gap: 10px;
+ overflow: hidden;
+ padding: 8px 14px;
+ white-space: nowrap;
+
+ & + & {
+ border-top: 1px solid rgba(0, 0, 0, 0.04);
+ }
+`;
+
+const FilePath = styled.p`
+ color: rgba(0, 0, 0, 0.68);
+ flex: 1 1 auto;
+ font-family: ${TERMINAL_TOKENS.font.mono};
+ font-size: 12px;
+ margin: 0;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const DiffAdded = styled.span`
+ color: #377e5d;
+ font-family: ${TERMINAL_TOKENS.font.mono};
+ font-size: 12px;
+ font-weight: 500;
+`;
+
+const DiffRemoved = styled.span`
+ color: #a94a4f;
+ font-family: ${TERMINAL_TOKENS.font.mono};
+ font-size: 12px;
+ font-weight: 500;
+`;
+
+const DiffStack = styled.div`
+ display: flex;
+ flex: 0 0 auto;
+ flex-direction: column;
+ padding: 8px 8px 12px;
+ width: 100%;
+`;
+
+const LineRow = styled.div<{ $change?: 'added' | 'removed' }>`
+ align-items: center;
+ background: ${({ $change }) => {
+ if ($change === 'added') {
+ return '#eaf4ed';
+ }
+ if ($change === 'removed') {
+ return '#faeceb';
+ }
+ return 'transparent';
+ }};
+ border-radius: 2px;
+ display: flex;
+ overflow: hidden;
+ position: relative;
+`;
+
+const ChangeBar = styled.span<{ $change: 'added' | 'removed' }>`
+ background: ${({ $change }) => ($change === 'added' ? '#82be9c' : '#d2989b')};
+ flex: 0 0 3px;
+ width: 3px;
+ align-self: stretch;
+`;
+
+const LineContent = styled.div<{ $indented?: boolean }>`
+ align-items: center;
+ display: flex;
+ flex: 1 1 auto;
+ gap: 12px;
+ min-width: 0;
+ overflow: hidden;
+ padding: 6px 12px 6px ${({ $indented }) => ($indented ? '9px' : '12px')};
+ white-space: nowrap;
+`;
+
+const LineNumber = styled.span`
+ color: rgba(0, 0, 0, 0.32);
+ flex: 0 0 auto;
+ font-family: ${TERMINAL_TOKENS.font.mono};
+ font-size: 12px;
+`;
+
+const LineText = styled.span`
+ color: rgba(0, 0, 0, 0.8);
+ flex: 1 1 auto;
+ font-family: ${TERMINAL_TOKENS.font.mono};
+ font-size: 12px;
+ line-height: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const UnmodRow = styled.div`
+ align-items: center;
+ display: flex;
+ padding: 6px 0;
+`;
+
+const UnmodChip = styled.div`
+ align-items: center;
+ background: rgba(0, 0, 0, 0.04);
+ border: 1px solid rgba(0, 0, 0, 0.04);
+ border-radius: 6px;
+ color: rgba(0, 0, 0, 0.55);
+ display: inline-flex;
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ font-size: 12px;
+ font-weight: 500;
+ gap: 10px;
+ padding: 6px 12px 6px 10px;
+`;
+
+const TOKEN_COLOR: Record = {
+ text: 'rgba(0, 0, 0, 0.8)',
+ keyword: '#8250df',
+ type: '#953800',
+ string: '#0a3069',
+ identifier: '#0550ae',
+};
+
+const renderToken = (token: DiffToken, index: number) => {
+ if (token.kind === 'text') {
+ return {token.value};
+ }
+ return (
+
+ {token.value}
+
+ );
+};
+
+const renderChunk = (chunk: DiffChunk, index: number) => {
+ if (chunk.kind === 'unmodified') {
+ return (
+
+
+
+ {chunk.count} unmodified lines
+
+
+ );
+ }
+ return (
+
+ {chunk.change ? : null}
+
+ {chunk.lineNumber}
+ {chunk.tokens.map(renderToken)}
+
+
+ );
+};
+
+const renderFile = (file: DiffFile) => (
+
+
+ {file.path}
+ +{file.added}
+ -{file.removed}
+
+ {file.chunks.map(renderChunk)}
+
+);
+
+export const TerminalDiff = () => {
+ return {DIFF_FILES.map(renderFile)};
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diffData.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diffData.ts
new file mode 100644
index 00000000000..1381208533e
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diffData.ts
@@ -0,0 +1,590 @@
+// Diff data shown in the slide-in review panel. Reflects the launch-ops CRM
+// expansion (Rocket, Launch, Payload, Customer, Launch site) that the chat
+// scaffolds — matches the file list in ChangesSummaryCard.
+
+import { CHANGESET_TOTALS } from '../conversation/rocketChangeset';
+
+export type DiffTokenKind =
+ | 'text'
+ | 'keyword'
+ | 'type'
+ | 'string'
+ | 'identifier';
+
+export type DiffToken = { kind: DiffTokenKind; value: string };
+
+export type DiffChunk =
+ | {
+ kind: 'line';
+ lineNumber: number;
+ tokens: DiffToken[];
+ change?: 'added' | 'removed';
+ }
+ | { kind: 'unmodified'; count: number };
+
+export type DiffFile = {
+ id: string;
+ path: string;
+ added: number;
+ removed: number;
+ chunks: DiffChunk[];
+};
+
+const kw = (value: string): DiffToken => ({ kind: 'keyword', value });
+const ty = (value: string): DiffToken => ({ kind: 'type', value });
+const st = (value: string): DiffToken => ({ kind: 'string', value });
+const id = (value: string): DiffToken => ({ kind: 'identifier', value });
+const tx = (value: string): DiffToken => ({ kind: 'text', value });
+
+export const DIFF_FILES: DiffFile[] = [
+ {
+ id: 'schema-identifiers',
+ path: '…my-twenty-app/src/constants/schema-identifiers.ts',
+ added: 84,
+ removed: 0,
+ chunks: [
+ {
+ kind: 'line',
+ lineNumber: 1,
+ change: 'added',
+ tokens: [
+ kw('export'),
+ tx(' '),
+ kw('const'),
+ tx(' '),
+ id('SCHEMA_IDS'),
+ tx(' = {'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 2,
+ change: 'added',
+ tokens: [
+ tx(' rocket: { object: '),
+ st("'733956fd-…'"),
+ tx(', fields: { '),
+ id('launches'),
+ tx(': '),
+ st("'5b877c2a-…'"),
+ tx(' } },'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 3,
+ change: 'added',
+ tokens: [
+ tx(' launch: { object: '),
+ st("'e7f1e750-…'"),
+ tx(', fields: { '),
+ id('rocket'),
+ tx(': '),
+ st("'42c9106f-…'"),
+ tx(' } },'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 4,
+ change: 'added',
+ tokens: [
+ tx(' payload: { object: '),
+ st("'16ffcc45-…'"),
+ tx(', fields: { '),
+ id('customer'),
+ tx(': '),
+ st("'d84468aa-…'"),
+ tx(' } },'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 5,
+ change: 'added',
+ tokens: [
+ tx(' launchSite: { object: '),
+ st("'2f18d525-…'"),
+ tx(', fields: { '),
+ id('launches'),
+ tx(': '),
+ st("'b94b7f00-…'"),
+ tx(' } },'),
+ ],
+ },
+ { kind: 'unmodified', count: 79 },
+ ],
+ },
+ {
+ id: 'launch-object',
+ path: '…my-twenty-app/src/objects/launch.object.ts',
+ added: 237,
+ removed: 0,
+ chunks: [
+ {
+ kind: 'line',
+ lineNumber: 1,
+ change: 'added',
+ tokens: [
+ kw('import'),
+ tx(' { '),
+ id('defineObject'),
+ tx(', '),
+ id('FieldType'),
+ tx(', '),
+ id('RelationType'),
+ tx(' } '),
+ kw('from'),
+ tx(' '),
+ st("'twenty-sdk'"),
+ tx(';'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 3,
+ change: 'added',
+ tokens: [
+ kw('export'),
+ tx(' '),
+ kw('default'),
+ tx(' '),
+ id('defineObject'),
+ tx('({'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 4,
+ change: 'added',
+ tokens: [tx(' nameSingular: '), st("'launch'"), tx(',')],
+ },
+ {
+ kind: 'line',
+ lineNumber: 5,
+ change: 'added',
+ tokens: [
+ tx(' labelSingular: '),
+ st("'Launch'"),
+ tx(', labelPlural: '),
+ st("'Launches'"),
+ tx(','),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 6,
+ change: 'added',
+ tokens: [tx(' icon: '), st("'IconRocket'"), tx(',')],
+ },
+ {
+ kind: 'line',
+ lineNumber: 7,
+ change: 'added',
+ tokens: [tx(' fields: [')],
+ },
+ {
+ kind: 'line',
+ lineNumber: 8,
+ change: 'added',
+ tokens: [
+ tx(' { name: '),
+ st("'missionCode'"),
+ tx(', type: '),
+ id('FieldType'),
+ tx('.'),
+ ty('TEXT'),
+ tx(', isUnique: '),
+ kw('true'),
+ tx(' },'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 9,
+ change: 'added',
+ tokens: [
+ tx(' { name: '),
+ st("'status'"),
+ tx(', type: '),
+ id('FieldType'),
+ tx('.'),
+ ty('SELECT'),
+ tx(', options: [ … ] },'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 10,
+ change: 'added',
+ tokens: [
+ tx(' { name: '),
+ st("'plannedLaunchAt'"),
+ tx(', type: '),
+ id('FieldType'),
+ tx('.'),
+ ty('DATE_TIME'),
+ tx(' },'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 11,
+ change: 'added',
+ tokens: [
+ tx(' { name: '),
+ st("'rocket'"),
+ tx(', type: '),
+ id('FieldType'),
+ tx('.'),
+ ty('RELATION'),
+ tx(', relationType: '),
+ id('RelationType'),
+ tx('.'),
+ ty('MANY_TO_ONE'),
+ tx(' },'),
+ ],
+ },
+ { kind: 'unmodified', count: 226 },
+ ],
+ },
+ {
+ id: 'payload-object',
+ path: '…my-twenty-app/src/objects/payload.object.ts',
+ added: 198,
+ removed: 0,
+ chunks: [
+ {
+ kind: 'line',
+ lineNumber: 1,
+ change: 'added',
+ tokens: [
+ kw('import'),
+ tx(' { '),
+ id('STANDARD_OBJECT'),
+ tx(' } '),
+ kw('from'),
+ tx(' '),
+ st("'twenty-sdk'"),
+ tx(';'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 4,
+ change: 'added',
+ tokens: [
+ tx(' { name: '),
+ st("'payloadType'"),
+ tx(', type: '),
+ id('FieldType'),
+ tx('.'),
+ ty('SELECT'),
+ tx(' },'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 5,
+ change: 'added',
+ tokens: [
+ tx(' { name: '),
+ st("'customer'"),
+ tx(', type: '),
+ id('FieldType'),
+ tx('.'),
+ ty('RELATION'),
+ tx(','),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 6,
+ change: 'added',
+ tokens: [
+ tx(' relationTargetObjectMetadataUniversalIdentifier: '),
+ id('STANDARD_OBJECT'),
+ tx('.company.universalIdentifier,'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 7,
+ change: 'added',
+ tokens: [
+ tx(' universalSettings: { relationType: '),
+ id('RelationType'),
+ tx('.'),
+ ty('MANY_TO_ONE'),
+ tx(', joinColumnName: '),
+ st("'companyId'"),
+ tx(' },'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 8,
+ change: 'added',
+ tokens: [tx(' },')],
+ },
+ { kind: 'unmodified', count: 190 },
+ ],
+ },
+ {
+ id: 'rocket-object',
+ path: '…my-twenty-app/src/objects/rocket.object.ts',
+ added: 28,
+ removed: 32,
+ chunks: [
+ { kind: 'unmodified', count: 140 },
+ {
+ kind: 'line',
+ lineNumber: 141,
+ change: 'removed',
+ tokens: [tx(' ')],
+ },
+ {
+ kind: 'line',
+ lineNumber: 141,
+ change: 'added',
+ tokens: [tx(' {')],
+ },
+ {
+ kind: 'line',
+ lineNumber: 142,
+ change: 'added',
+ tokens: [tx(' name: '), st("'launches'"), tx(',')],
+ },
+ {
+ kind: 'line',
+ lineNumber: 143,
+ change: 'added',
+ tokens: [
+ tx(' type: '),
+ id('FieldType'),
+ tx('.'),
+ ty('RELATION'),
+ tx(','),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 144,
+ change: 'added',
+ tokens: [
+ tx(' relationType: '),
+ id('RelationType'),
+ tx('.'),
+ ty('ONE_TO_MANY'),
+ tx(','),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 145,
+ change: 'added',
+ tokens: [
+ tx(' relationTargetObjectMetadataUniversalIdentifier: '),
+ id('SCHEMA_IDS'),
+ tx('.launch.object,'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 146,
+ change: 'added',
+ tokens: [tx(' },')],
+ },
+ ],
+ },
+ {
+ id: 'upcoming-launches-view',
+ path: '…my-twenty-app/src/views/upcoming-launches.view.ts',
+ added: 82,
+ removed: 0,
+ chunks: [
+ {
+ kind: 'line',
+ lineNumber: 1,
+ change: 'added',
+ tokens: [
+ kw('import'),
+ tx(' { '),
+ id('defineView'),
+ tx(', '),
+ id('ViewFilterOperand'),
+ tx(', '),
+ id('ViewType'),
+ tx(' } '),
+ kw('from'),
+ tx(' '),
+ st("'twenty-sdk'"),
+ tx(';'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 3,
+ change: 'added',
+ tokens: [
+ kw('export'),
+ tx(' '),
+ kw('default'),
+ tx(' '),
+ id('defineView'),
+ tx('({'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 4,
+ change: 'added',
+ tokens: [tx(' name: '), st("'Upcoming launches'"), tx(',')],
+ },
+ {
+ kind: 'line',
+ lineNumber: 5,
+ change: 'added',
+ tokens: [tx(' type: '), id('ViewType'), tx('.'), ty('TABLE'), tx(',')],
+ },
+ {
+ kind: 'line',
+ lineNumber: 6,
+ change: 'added',
+ tokens: [tx(' filters: [')],
+ },
+ {
+ kind: 'line',
+ lineNumber: 7,
+ change: 'added',
+ tokens: [
+ tx(' { fieldMetadataUniversalIdentifier: '),
+ id('SCHEMA_IDS'),
+ tx('.launch.fields.plannedLaunchAt,'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 8,
+ change: 'added',
+ tokens: [
+ tx(' operand: '),
+ id('ViewFilterOperand'),
+ tx('.'),
+ ty('IS_IN_FUTURE'),
+ tx(' },'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 9,
+ change: 'added',
+ tokens: [tx(' ],')],
+ },
+ { kind: 'unmodified', count: 73 },
+ ],
+ },
+ {
+ id: 'schema-integration-test',
+ path: '…my-twenty-app/src/__tests__/schema.integration-test.ts',
+ added: 412,
+ removed: 40,
+ chunks: [
+ { kind: 'unmodified', count: 150 },
+ {
+ kind: 'line',
+ lineNumber: 151,
+ change: 'removed',
+ tokens: [
+ tx(' '),
+ id('expect'),
+ tx('(application.objects).'),
+ id('toHaveLength'),
+ tx('('),
+ ty('1'),
+ tx(');'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 151,
+ change: 'added',
+ tokens: [
+ tx(' '),
+ id('expect'),
+ tx('(application.objects).'),
+ id('toHaveLength'),
+ tx('('),
+ ty('4'),
+ tx(');'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 152,
+ change: 'added',
+ tokens: [
+ tx(' '),
+ id('expectObject'),
+ tx('(application.objects, '),
+ id('SCHEMA_IDS'),
+ tx('.launch.object, '),
+ st("'launch'"),
+ tx(', '),
+ st("'Launch'"),
+ tx(');'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 153,
+ change: 'added',
+ tokens: [
+ tx(' '),
+ id('expectObject'),
+ tx('(application.objects, '),
+ id('SCHEMA_IDS'),
+ tx('.payload.object, '),
+ st("'payload'"),
+ tx(', '),
+ st("'Payload'"),
+ tx(');'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 154,
+ change: 'added',
+ tokens: [
+ tx(' '),
+ id('expectObject'),
+ tx('(application.objects, '),
+ id('SCHEMA_IDS'),
+ tx('.launchSite.object, '),
+ st("'launchSite'"),
+ tx(', '),
+ st("'Launch site'"),
+ tx(');'),
+ ],
+ },
+ {
+ kind: 'line',
+ lineNumber: 155,
+ change: 'added',
+ tokens: [
+ tx(' '),
+ id('expectRelationPair'),
+ tx('(payloadCustomerField, [payload.object, '),
+ id('STANDARD_OBJECT'),
+ tx('.company.universalIdentifier], [...]);'),
+ ],
+ },
+ { kind: 'unmodified', count: 325 },
+ ],
+ },
+];
+
+// The diff panel only ships a sampler of files; the top-bar pill summarizes
+// the full changeset, so derive totals from the canonical changeset to keep
+// the pill consistent with the ChangesSummaryCard header.
+export const DIFF_TOTALS = CHANGESET_TOTALS;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditor.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditor.tsx
new file mode 100644
index 00000000000..6c15826f80c
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditor.tsx
@@ -0,0 +1,505 @@
+'use client';
+
+import { IconX } from '@tabler/icons-react';
+import { styled } from '@linaria/react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { TERMINAL_TOKENS } from '../terminalTokens';
+import {
+ DEFAULT_EDITOR_FILE_ID,
+ EDITOR_FILES,
+ EXPLORER_NODES,
+ findFileById,
+ GENERATED_FILE_IDS,
+ STARTER_EDITOR_FILE_ID,
+ tokenizeSource,
+ type CodeToken,
+ type EditorFile,
+ type ExplorerNode,
+ type FileIconKind,
+ type TokenKind,
+} from './editorData';
+import { EDITOR_TOKENS } from './editorTokens';
+
+const Root = styled.div`
+ background: ${EDITOR_TOKENS.surface.body};
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ width: 100%;
+`;
+
+const Sidebar = styled.div`
+ background: ${EDITOR_TOKENS.surface.sidebar};
+ border-right: 1px solid ${EDITOR_TOKENS.surface.sidebarBorder};
+ display: flex;
+ flex: 0 0 206px;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ padding-bottom: 12px;
+ width: 206px;
+`;
+
+const ExplorerHeader = styled.div`
+ align-items: center;
+ border-bottom: 1px solid ${EDITOR_TOKENS.surface.explorerHeaderBorder};
+ color: ${EDITOR_TOKENS.text.explorerLabel};
+ display: flex;
+ flex: 0 0 36px;
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ font-size: 11px;
+ font-weight: 500;
+ height: 36px;
+ letter-spacing: 0.4px;
+ padding: 0 12px 0 14px;
+`;
+
+const FileTree = styled.div`
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+ overflow-y: auto;
+ padding-top: 4px;
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.06);
+ border-radius: 999px;
+ }
+`;
+
+const FileRowStatic = styled.div<{ $active?: boolean; $depth: number }>`
+ align-items: center;
+ background: ${({ $active }) =>
+ $active ? EDITOR_TOKENS.surface.activeRow : 'transparent'};
+ display: flex;
+ flex: 0 0 24px;
+ gap: 6px;
+ height: 24px;
+ overflow: hidden;
+ padding-left: ${({ $depth }) => `${12 + $depth * 14}px`};
+ padding-right: 12px;
+ white-space: nowrap;
+`;
+
+const FileRowButton = styled.button<{ $active?: boolean; $depth: number }>`
+ align-items: center;
+ background: ${({ $active }) =>
+ $active ? EDITOR_TOKENS.surface.activeRow : 'transparent'};
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ display: flex;
+ flex: 0 0 24px;
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ gap: 6px;
+ height: 24px;
+ overflow: hidden;
+ padding-left: ${({ $depth }) => `${12 + $depth * 14}px`};
+ padding-right: 12px;
+ text-align: left;
+ transition: background-color 0.14s ease;
+ white-space: nowrap;
+ width: 100%;
+
+ &:hover {
+ background: ${({ $active }) =>
+ $active ? EDITOR_TOKENS.surface.activeRow : 'rgba(255, 255, 255, 0.04)'};
+ }
+`;
+
+const Caret = styled.span`
+ color: ${EDITOR_TOKENS.text.caret};
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ font-size: 10px;
+`;
+
+const FolderName = styled.span`
+ color: ${EDITOR_TOKENS.text.secondary};
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ font-size: 12px;
+`;
+
+const FileName = styled.span<{ $active?: boolean }>`
+ color: ${({ $active }) =>
+ $active ? EDITOR_TOKENS.text.primary : EDITOR_TOKENS.text.muted};
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ font-size: 12px;
+ font-weight: ${({ $active }) => ($active ? 500 : 400)};
+`;
+
+const FILE_ICON_COLOR: Record = {
+ ts: EDITOR_TOKENS.fileIcon.ts,
+ md: EDITOR_TOKENS.fileIcon.md,
+ js: EDITOR_TOKENS.fileIcon.js,
+ git: EDITOR_TOKENS.fileIcon.git,
+ yaml: EDITOR_TOKENS.fileIcon.yaml,
+ cf: EDITOR_TOKENS.fileIcon.cf,
+ lock: EDITOR_TOKENS.fileIcon.lock,
+};
+
+const FileIcon = styled.span<{ $color: string }>`
+ color: ${({ $color }) => $color};
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ font-size: 9px;
+ font-weight: 500;
+ letter-spacing: 0.2px;
+`;
+
+const EditorShell = styled.div`
+ background: ${EDITOR_TOKENS.surface.body};
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+ min-width: 0;
+`;
+
+const TabBar = styled.div`
+ background: ${EDITOR_TOKENS.surface.tabBar};
+ border-bottom: 1px solid ${EDITOR_TOKENS.surface.tabBarBorder};
+ display: flex;
+ flex: 0 0 36px;
+ height: 36px;
+ overflow-x: auto;
+ overflow-y: hidden;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+`;
+
+const Tab = styled.div<{ $active?: boolean }>`
+ align-items: center;
+ background: ${({ $active }) =>
+ $active ? EDITOR_TOKENS.surface.activeTab : 'transparent'};
+ border-right: 1px solid ${EDITOR_TOKENS.surface.tabBarBorder};
+ cursor: pointer;
+ display: flex;
+ flex: 0 0 auto;
+ gap: 8px;
+ height: 36px;
+ padding: 0 12px 0 14px;
+ position: relative;
+ transition: background-color 0.14s ease;
+
+ &::before {
+ background: ${({ $active }) =>
+ $active ? EDITOR_TOKENS.surface.activeTabAccent : 'transparent'};
+ content: '';
+ height: 1px;
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: 0;
+ }
+
+ &:hover {
+ background: ${({ $active }) =>
+ $active ? EDITOR_TOKENS.surface.activeTab : 'rgba(255, 255, 255, 0.03)'};
+ }
+`;
+
+const TabFileIcon = styled.span`
+ color: ${EDITOR_TOKENS.text.tabAccent};
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.4px;
+`;
+
+const TabTitle = styled.span<{ $active?: boolean }>`
+ color: ${({ $active }) =>
+ $active ? EDITOR_TOKENS.text.active : EDITOR_TOKENS.text.muted};
+ flex: 1 1 auto;
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ font-size: 12px;
+ font-weight: ${({ $active }) => ($active ? 500 : 400)};
+ white-space: nowrap;
+`;
+
+const TabClose = styled.span`
+ align-items: center;
+ color: ${EDITOR_TOKENS.text.dim};
+ display: flex;
+ flex: 0 0 14px;
+ height: 14px;
+ justify-content: center;
+ width: 14px;
+`;
+
+const CodeRegion = styled.div`
+ background: ${EDITOR_TOKENS.surface.body};
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+ overflow: auto;
+ padding: 12px 0;
+ scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
+
+ &::-webkit-scrollbar {
+ background: ${EDITOR_TOKENS.surface.body};
+ height: 8px;
+ width: 8px;
+ }
+ &::-webkit-scrollbar-track {
+ background: ${EDITOR_TOKENS.surface.body};
+ }
+ &::-webkit-scrollbar-corner {
+ background: ${EDITOR_TOKENS.surface.body};
+ }
+ &::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.12);
+ border-radius: 999px;
+ }
+`;
+
+const CodeStack = styled.div`
+ display: flex;
+ flex-direction: column;
+ min-width: min-content;
+`;
+
+const CodeLineRow = styled.div`
+ align-items: center;
+ display: flex;
+ height: 20px;
+ min-width: max-content;
+`;
+
+const Gutter = styled.div`
+ color: ${EDITOR_TOKENS.text.gutter};
+ display: flex;
+ flex: 0 0 52px;
+ font-family: ${TERMINAL_TOKENS.font.mono};
+ font-size: 12px;
+ height: 20px;
+ justify-content: flex-end;
+ padding-right: 12px;
+ user-select: none;
+ width: 52px;
+`;
+
+const CodeText = styled.pre`
+ color: ${EDITOR_TOKENS.text.code};
+ font-family: ${TERMINAL_TOKENS.font.mono};
+ font-size: 13px;
+ line-height: 20px;
+ margin: 0;
+ padding: 0;
+ white-space: pre;
+`;
+
+const TOKEN_COLOR: Record = {
+ text: EDITOR_TOKENS.text.code,
+ keyword: EDITOR_TOKENS.syntax.keyword,
+ function: EDITOR_TOKENS.syntax.function,
+ string: EDITOR_TOKENS.syntax.string,
+ property: EDITOR_TOKENS.syntax.property,
+ identifier: EDITOR_TOKENS.syntax.identifier,
+ comment: EDITOR_TOKENS.syntax.comment,
+};
+
+const renderCodeToken = (token: CodeToken, index: number) => {
+ if (token.kind === 'text') {
+ return {token.value};
+ }
+ return (
+
+ {token.value}
+
+ );
+};
+
+const renderExplorerNode = (
+ node: ExplorerNode,
+ activeFileId: string,
+ onSelect: (fileId: string) => void,
+) => {
+ if (node.kind === 'folder') {
+ return (
+
+ {node.expanded ? '▾' : '▸'}
+ {node.name}
+
+ );
+ }
+
+ const isSelectable = Boolean(node.fileId);
+ const isActive = node.fileId !== undefined && node.fileId === activeFileId;
+
+ if (!isSelectable) {
+ return (
+
+
+ {node.iconLabel}
+
+ {node.name}
+
+ );
+ }
+
+ return (
+ onSelect(node.fileId as string)}
+ type="button"
+ >
+ {node.iconLabel}
+ {node.name}
+
+ );
+};
+
+type TerminalEditorProps = {
+ // When false, hide files scaffolded by the assistant so the editor can be
+ // opened before the chat has run.
+ showGeneratedFiles?: boolean;
+};
+
+// Browser-like editor: click files in the tree or tabs in the top bar to swap
+// the visible buffer. Open tabs track files the visitor has touched during
+// this session so they can jump back quickly.
+export const TerminalEditor = ({
+ showGeneratedFiles = true,
+}: TerminalEditorProps) => {
+ const fallbackFileId = showGeneratedFiles
+ ? DEFAULT_EDITOR_FILE_ID
+ : STARTER_EDITOR_FILE_ID;
+
+ const [activeFileId, setActiveFileId] = useState(fallbackFileId);
+ const [openFileIds, setOpenFileIds] = useState([fallbackFileId]);
+
+ // When chat hasn't run, generated tabs must not be visible. When chat
+ // finishes, pop open the default generated file so the new state is
+ // discoverable without the user hunting for it in the tree.
+ useEffect(() => {
+ if (!showGeneratedFiles) {
+ setOpenFileIds((current) => {
+ const next = current.filter((id) => !GENERATED_FILE_IDS.has(id));
+ return next.length > 0 ? next : [STARTER_EDITOR_FILE_ID];
+ });
+ setActiveFileId((current) =>
+ GENERATED_FILE_IDS.has(current) ? STARTER_EDITOR_FILE_ID : current,
+ );
+ return;
+ }
+ setActiveFileId(DEFAULT_EDITOR_FILE_ID);
+ setOpenFileIds((current) =>
+ current.includes(DEFAULT_EDITOR_FILE_ID)
+ ? current
+ : [...current, DEFAULT_EDITOR_FILE_ID],
+ );
+ }, [showGeneratedFiles]);
+
+ const handleSelectFile = useCallback((fileId: string) => {
+ setActiveFileId(fileId);
+ setOpenFileIds((current) =>
+ current.includes(fileId) ? current : [...current, fileId],
+ );
+ }, []);
+
+ const handleCloseTab = useCallback(
+ (event: React.MouseEvent, fileId: string) => {
+ event.stopPropagation();
+ setOpenFileIds((current) => {
+ const next = current.filter((id) => id !== fileId);
+ if (next.length === 0) {
+ // Keep the fallback file visible so the editor never empties.
+ return [fallbackFileId];
+ }
+ return next;
+ });
+ setActiveFileId((current) => {
+ if (current !== fileId) {
+ return current;
+ }
+ const remaining = openFileIds.filter((id) => id !== fileId);
+ return remaining[remaining.length - 1] ?? fallbackFileId;
+ });
+ },
+ [fallbackFileId, openFileIds],
+ );
+
+ const visibleExplorerNodes = useMemo(
+ () =>
+ showGeneratedFiles
+ ? EXPLORER_NODES
+ : EXPLORER_NODES.filter(
+ (node) => !('generated' in node && node.generated),
+ ),
+ [showGeneratedFiles],
+ );
+
+ const activeFile: EditorFile =
+ findFileById(activeFileId) ??
+ findFileById(fallbackFileId) ??
+ EDITOR_FILES[0];
+
+ const codeLines = useMemo(
+ () => tokenizeSource(activeFile.source),
+ [activeFile.source],
+ );
+
+ const openFiles = useMemo(
+ () =>
+ openFileIds
+ .map((id) => findFileById(id))
+ .filter((file): file is EditorFile => file !== undefined),
+ [openFileIds],
+ );
+
+ return (
+
+
+ Explorer
+
+ {visibleExplorerNodes.map((node) =>
+ renderExplorerNode(node, activeFileId, handleSelectFile),
+ )}
+
+
+
+
+ {openFiles.map((file) => {
+ const isActive = file.id === activeFileId;
+ return (
+ setActiveFileId(file.id)}
+ >
+ TS
+ {file.name}
+ handleCloseTab(event, file.id)}
+ >
+
+
+
+ );
+ })}
+
+
+
+ {codeLines.map((line, index) => (
+
+ {index + 1}
+
+ {line.length === 0 ? ' ' : line.map(renderCodeToken)}
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/editorData.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/editorData.ts
new file mode 100644
index 00000000000..ae90b0256f4
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/editorData.ts
@@ -0,0 +1,1880 @@
+export type FileIconKind = 'ts' | 'md' | 'js' | 'git' | 'yaml' | 'cf' | 'lock';
+
+export type ExplorerNode = {
+ id: string;
+ name: string;
+ depth: number;
+} & (
+ | { kind: 'folder'; expanded: boolean; generated?: boolean }
+ | {
+ kind: 'file';
+ icon: FileIconKind;
+ iconLabel: string;
+ fileId?: string;
+ generated?: boolean;
+ }
+);
+
+export type TokenKind =
+ | 'text'
+ | 'keyword'
+ | 'function'
+ | 'string'
+ | 'property'
+ | 'identifier'
+ | 'comment';
+
+export type CodeToken = { kind: TokenKind; value: string };
+
+export type CodeLine = CodeToken[];
+
+export type EditorFile = {
+ id: string;
+ name: string;
+ path: string;
+ source: string;
+};
+
+// -- Source files --
+//
+// Copies of the rocket-app schema files the assistant scaffolds. Embedded
+// directly so the preview can render multiple tabs without needing the real
+// filesystem at runtime. Keep in rough sync with
+// /Users/thomascolasdesfrancs/code/Twenty-apps/rockets-app/src/.
+
+const schemaIdentifiersSource = `export const SCHEMA_IDS = {
+ rocket: {
+ object: '733956fd-c19c-4a13-a6c2-e92d6e28bcb9',
+ fields: {
+ name: '0c69e10f-77c5-4e47-ad62-53445ff3bf9c',
+ serialNumber: '1f381732-1c7b-4939-a54d-b6a4ff7366d2',
+ manufacturer: 'fdbfddab-6424-4726-8eaa-5f853c401939',
+ status: 'f333e670-fbde-494d-a46b-a37bff24b37d',
+ reusable: 'aed92b19-1f0c-4812-8d40-e18e2866b719',
+ launchDate: 'fcc6d872-82ca-4d99-bd1d-659d1b3ad46c',
+ heightMeters: '24670e09-3e4d-4b38-bbfa-4a4316f4ecfe',
+ massKg: 'b6a019de-df55-4210-9445-d83df1f70f86',
+ targetOrbit: 'dcd879a5-bc60-4225-85e1-0085ccab1b0d',
+ launches: '5b877c2a-d10b-49e8-9c30-b9202401ec35',
+ },
+ views: {
+ index: '4587a1a3-0c5f-4f60-9996-d77a7233ce26',
+ },
+ commandMenuItems: {
+ flyAgain: '2a3c2b88-20ad-4ff6-9c4f-52f2e2f1d801',
+ scheduleLaunch: 'cfcb4a13-9a9c-4b20-9c58-1a87e7a63c62',
+ retireRocket: '36c54890-6a9d-4c79-95c1-3b9b3d33ea33',
+ },
+ },
+ launch: {
+ object: 'e7f1e750-5883-4e71-8b22-1c5258d8faa7',
+ fields: {
+ name: '92bc09d1-5712-4877-aa6d-7b5900267935',
+ missionCode: 'cc2e7d7a-93e5-4369-9682-7bf8134a01e9',
+ status: '68af6212-15fe-427d-bfa3-19a4a7ab48c2',
+ missionType: 'd5f816a5-456e-41c3-b7b3-f3259703146b',
+ plannedLaunchAt: '382820f2-8f47-4b3b-a34a-83d1d707a350',
+ actualLaunchAt: '2a255cee-419b-4151-b8bd-3e11a2c6c2b2',
+ summary: '22175eb0-44b0-493b-ad1e-f2c545e60c3d',
+ rocket: '42c9106f-41e1-467a-bb00-c4442f4541f8',
+ launchSite: '92221a22-9356-485d-ace0-f682f38ae6fe',
+ payloads: '902c5fc3-1d22-4593-8e23-ea23cab36d20',
+ },
+ views: {
+ index: '92f768d9-e6c1-4070-b0c8-799204872a00',
+ upcoming: 'd62590d5-aa52-4d77-bca7-225453ed659f',
+ past: 'f3ede3df-04eb-45a8-991e-a8b324bbbb16',
+ },
+ commandMenuItems: {
+ rescheduleLaunch: '16a67c2f-0ed1-4d4b-b40e-0c5d0c0d40a1',
+ addPayload: 'cb0f2af2-5c9d-4f9b-8a12-3cafecdde7a1',
+ upcomingLaunches: 'c4c3a0dd-2d7c-4fc6-a2f7-eb6a89fc8d01',
+ },
+ },
+ payload: {
+ object: '16ffcc45-b097-4031-a768-ec62a23dd8d3',
+ fields: {
+ name: '5018c89c-80e1-452b-9e0f-0eda5351c972',
+ payloadType: '0ee139c4-b701-49f8-9f58-5e79fc1a08bd',
+ status: '2ea1b614-9aa3-41ce-b253-97919f9544ee',
+ massKg: 'd2a52145-f3d1-4657-9734-b96e965408a3',
+ customer: 'd84468aa-5b75-4ae4-836e-7fff0ce58c91',
+ launch: 'eb65b7f1-0780-4a9b-9fcc-528675ef0967',
+ },
+ commandMenuItems: {
+ bookSlot: '01c44f8c-15db-40e7-8b57-7d7d0f9f2fa0',
+ setPayloadStatus: '8f7cd03b-3fd6-4c65-b0c4-2ec0a4a42a5e',
+ },
+ },
+ company: {
+ commandMenuItems: {
+ setCustomerStatus: '2a4fa56d-5d8a-4e55-8c1e-27e8fc6f1d63',
+ },
+ },
+ launchSite: {
+ object: '2f18d525-0068-4d13-a26d-96eb31ed7646',
+ fields: {
+ name: 'ad99750c-c083-4cb6-8985-0b0e768dcced',
+ siteCode: '97bebd5b-2bd2-4ff3-a7df-c753f68db1aa',
+ siteStatus: '1031ca3c-c12a-463c-b522-4547d06c0cc0',
+ launches: 'b94b7f00-a5ef-4179-982b-1ea68f94ecff',
+ },
+ commandMenuItems: {
+ setSiteStatus: '77e56e08-fb0a-4d4a-b9b2-9a3e5b8af1b6',
+ bookWindow: 'b11a9c20-0bcb-4f73-9ef9-6e80d39b8c7e',
+ launchesFromSite: 'a5ce6cd9-1a45-4a7f-9a61-0a0a6ef1cf4a',
+ },
+ },
+} as const;
+`;
+
+const rocketObjectSource = `import { defineObject, FieldType, RelationType } from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+enum RocketStatus {
+ Planned = 'PLANNED',
+ Active = 'ACTIVE',
+ Retired = 'RETIRED',
+ Lost = 'LOST',
+}
+
+export default defineObject({
+ universalIdentifier: SCHEMA_IDS.rocket.object,
+ nameSingular: 'rocket',
+ namePlural: 'rockets',
+ labelSingular: 'Rocket',
+ labelPlural: 'Rockets',
+ description: 'Tracks rockets, their launch profile, and operating status.',
+ icon: 'IconRocket',
+ isSearchable: true,
+ labelIdentifierFieldMetadataUniversalIdentifier:
+ SCHEMA_IDS.rocket.fields.name,
+ fields: [
+ {
+ universalIdentifier: SCHEMA_IDS.rocket.fields.name,
+ type: FieldType.TEXT,
+ name: 'name',
+ label: 'Name',
+ icon: 'IconRocket',
+ isNullable: false,
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.rocket.fields.serialNumber,
+ type: FieldType.TEXT,
+ name: 'serialNumber',
+ label: 'Serial Number',
+ icon: 'IconHash',
+ isNullable: false,
+ isUnique: true,
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.rocket.fields.status,
+ type: FieldType.SELECT,
+ name: 'status',
+ label: 'Status',
+ icon: 'IconFlag',
+ isNullable: false,
+ options: [
+ { label: 'Planned', value: RocketStatus.Planned, color: 'sky' },
+ { label: 'Active', value: RocketStatus.Active, color: 'green' },
+ { label: 'Retired', value: RocketStatus.Retired, color: 'gray' },
+ { label: 'Lost', value: RocketStatus.Lost, color: 'red' },
+ ],
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.rocket.fields.launches,
+ type: FieldType.RELATION,
+ name: 'launches',
+ label: 'Launches',
+ icon: 'IconRocket',
+ isNullable: true,
+ relationTargetFieldMetadataUniversalIdentifier:
+ SCHEMA_IDS.launch.fields.rocket,
+ relationTargetObjectMetadataUniversalIdentifier:
+ SCHEMA_IDS.launch.object,
+ universalSettings: {
+ relationType: RelationType.ONE_TO_MANY,
+ },
+ },
+ ],
+});
+`;
+
+const launchObjectSource = `import {
+ defineObject,
+ FieldType,
+ OnDeleteAction,
+ RelationType,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+enum LaunchStatus {
+ Draft = 'DRAFT',
+ Scheduled = 'SCHEDULED',
+ Scrubbed = 'SCRUBBED',
+ Launched = 'LAUNCHED',
+ Success = 'SUCCESS',
+ Failure = 'FAILURE',
+}
+
+enum MissionType {
+ Commercial = 'COMMERCIAL',
+ Crewed = 'CREWED',
+ Cargo = 'CARGO',
+ Test = 'TEST',
+}
+
+export default defineObject({
+ universalIdentifier: SCHEMA_IDS.launch.object,
+ nameSingular: 'launch',
+ namePlural: 'launches',
+ labelSingular: 'Launch',
+ labelPlural: 'Launches',
+ description: 'Tracks planned and completed launch missions.',
+ icon: 'IconRocket',
+ fields: [
+ {
+ universalIdentifier: SCHEMA_IDS.launch.fields.missionCode,
+ type: FieldType.TEXT,
+ name: 'missionCode',
+ label: 'Mission Code',
+ isUnique: true,
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.launch.fields.status,
+ type: FieldType.SELECT,
+ name: 'status',
+ label: 'Status',
+ options: [
+ { label: 'Scheduled', value: LaunchStatus.Scheduled, color: 'sky' },
+ { label: 'Success', value: LaunchStatus.Success, color: 'green' },
+ { label: 'Failure', value: LaunchStatus.Failure, color: 'red' },
+ ],
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.launch.fields.plannedLaunchAt,
+ type: FieldType.DATE_TIME,
+ name: 'plannedLaunchAt',
+ label: 'Planned Launch',
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.launch.fields.rocket,
+ type: FieldType.RELATION,
+ name: 'rocket',
+ label: 'Rocket',
+ relationTargetFieldMetadataUniversalIdentifier:
+ SCHEMA_IDS.rocket.fields.launches,
+ relationTargetObjectMetadataUniversalIdentifier: SCHEMA_IDS.rocket.object,
+ universalSettings: {
+ relationType: RelationType.MANY_TO_ONE,
+ onDelete: OnDeleteAction.SET_NULL,
+ joinColumnName: 'rocketId',
+ },
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.launch.fields.launchSite,
+ type: FieldType.RELATION,
+ name: 'launchSite',
+ label: 'Launch site',
+ relationTargetFieldMetadataUniversalIdentifier:
+ SCHEMA_IDS.launchSite.fields.launches,
+ relationTargetObjectMetadataUniversalIdentifier:
+ SCHEMA_IDS.launchSite.object,
+ universalSettings: {
+ relationType: RelationType.MANY_TO_ONE,
+ onDelete: OnDeleteAction.SET_NULL,
+ joinColumnName: 'launchSiteId',
+ },
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.launch.fields.payloads,
+ type: FieldType.RELATION,
+ name: 'payloads',
+ label: 'Payloads',
+ relationTargetObjectMetadataUniversalIdentifier:
+ SCHEMA_IDS.payload.object,
+ universalSettings: {
+ relationType: RelationType.ONE_TO_MANY,
+ },
+ },
+ ],
+});
+`;
+
+const payloadObjectSource = `import {
+ defineObject,
+ FieldType,
+ OnDeleteAction,
+ RelationType,
+ STANDARD_OBJECT,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+enum PayloadType {
+ Satellite = 'SATELLITE',
+ CrewCapsule = 'CREW_CAPSULE',
+ Cargo = 'CARGO',
+ Probe = 'PROBE',
+}
+
+enum PayloadStatus {
+ Manifested = 'MANIFESTED',
+ Integrated = 'INTEGRATED',
+ Launched = 'LAUNCHED',
+ Lost = 'LOST',
+}
+
+export default defineObject({
+ universalIdentifier: SCHEMA_IDS.payload.object,
+ nameSingular: 'payload',
+ namePlural: 'payloads',
+ labelSingular: 'Payload',
+ labelPlural: 'Payloads',
+ description: 'Tracks payload manifests and customer bookings.',
+ icon: 'IconBox',
+ fields: [
+ {
+ universalIdentifier: SCHEMA_IDS.payload.fields.payloadType,
+ type: FieldType.SELECT,
+ name: 'payloadType',
+ label: 'Payload Type',
+ options: [
+ { label: 'Satellite', value: PayloadType.Satellite, color: 'blue' },
+ { label: 'Crew Capsule', value: PayloadType.CrewCapsule, color: 'purple' },
+ { label: 'Cargo', value: PayloadType.Cargo, color: 'orange' },
+ { label: 'Probe', value: PayloadType.Probe, color: 'turquoise' },
+ ],
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.payload.fields.status,
+ type: FieldType.SELECT,
+ name: 'status',
+ label: 'Status',
+ options: [
+ { label: 'Manifested', value: PayloadStatus.Manifested, color: 'sky' },
+ { label: 'Integrated', value: PayloadStatus.Integrated, color: 'blue' },
+ { label: 'Launched', value: PayloadStatus.Launched, color: 'green' },
+ { label: 'Lost', value: PayloadStatus.Lost, color: 'red' },
+ ],
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.payload.fields.massKg,
+ type: FieldType.NUMBER,
+ name: 'massKg',
+ label: 'Mass (kg)',
+ },
+ {
+ // customer is a relation to the standard Companies object (no new
+ // Customer object — we reuse what ships with Twenty).
+ universalIdentifier: SCHEMA_IDS.payload.fields.customer,
+ type: FieldType.RELATION,
+ name: 'customer',
+ label: 'Customer',
+ relationTargetObjectMetadataUniversalIdentifier:
+ STANDARD_OBJECT.company.universalIdentifier,
+ universalSettings: {
+ relationType: RelationType.MANY_TO_ONE,
+ onDelete: OnDeleteAction.SET_NULL,
+ joinColumnName: 'companyId',
+ },
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.payload.fields.launch,
+ type: FieldType.RELATION,
+ name: 'launch',
+ label: 'Launch',
+ relationTargetObjectMetadataUniversalIdentifier: SCHEMA_IDS.launch.object,
+ universalSettings: {
+ relationType: RelationType.MANY_TO_ONE,
+ onDelete: OnDeleteAction.SET_NULL,
+ joinColumnName: 'launchId',
+ },
+ },
+ ],
+});
+`;
+
+const launchSiteObjectSource = `import { defineObject, FieldType, RelationType } from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+enum LaunchSiteStatus {
+ Active = 'ACTIVE',
+ Standby = 'STANDBY',
+ Maintenance = 'MAINTENANCE',
+ Retired = 'RETIRED',
+}
+
+export default defineObject({
+ universalIdentifier: SCHEMA_IDS.launchSite.object,
+ nameSingular: 'launchSite',
+ namePlural: 'launchSites',
+ labelSingular: 'Launch site',
+ labelPlural: 'Launch sites',
+ description: 'Tracks launch pads, regions, and site readiness.',
+ icon: 'IconMapPin',
+ fields: [
+ {
+ universalIdentifier: SCHEMA_IDS.launchSite.fields.siteCode,
+ type: FieldType.TEXT,
+ name: 'siteCode',
+ label: 'Site Code',
+ isUnique: true,
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.launchSite.fields.siteStatus,
+ type: FieldType.SELECT,
+ name: 'siteStatus',
+ label: 'Site Status',
+ options: [
+ { label: 'Active', value: LaunchSiteStatus.Active, color: 'green' },
+ { label: 'Standby', value: LaunchSiteStatus.Standby, color: 'sky' },
+ { label: 'Retired', value: LaunchSiteStatus.Retired, color: 'gray' },
+ ],
+ },
+ {
+ universalIdentifier: SCHEMA_IDS.launchSite.fields.launches,
+ type: FieldType.RELATION,
+ name: 'launches',
+ label: 'Launches',
+ relationTargetFieldMetadataUniversalIdentifier:
+ SCHEMA_IDS.launch.fields.launchSite,
+ relationTargetObjectMetadataUniversalIdentifier: SCHEMA_IDS.launch.object,
+ universalSettings: {
+ relationType: RelationType.ONE_TO_MANY,
+ },
+ },
+ ],
+});
+`;
+
+const launchesViewSource = `import { defineView, ViewKey } from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineView({
+ universalIdentifier: SCHEMA_IDS.launch.views.index,
+ name: 'Launches',
+ objectUniversalIdentifier: SCHEMA_IDS.launch.object,
+ icon: 'IconRocket',
+ key: ViewKey.INDEX,
+ position: 0,
+ fields: [
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.launch.fields.name,
+ position: 0,
+ isVisible: true,
+ size: 220,
+ },
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.launch.fields.missionCode,
+ position: 1,
+ isVisible: true,
+ size: 150,
+ },
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.launch.fields.status,
+ position: 2,
+ isVisible: true,
+ size: 140,
+ },
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.launch.fields.rocket,
+ position: 3,
+ isVisible: true,
+ size: 180,
+ },
+ ],
+});
+`;
+
+const upcomingLaunchesViewSource = `import {
+ defineView,
+ ViewFilterOperand,
+ ViewType,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineView({
+ universalIdentifier: SCHEMA_IDS.launch.views.upcoming,
+ name: 'Upcoming launches',
+ objectUniversalIdentifier: SCHEMA_IDS.launch.object,
+ icon: 'IconCalendarEvent',
+ type: ViewType.TABLE,
+ position: 1,
+ filters: [
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.launch.fields.plannedLaunchAt,
+ operand: ViewFilterOperand.IS_IN_FUTURE,
+ value: {},
+ },
+ ],
+});
+`;
+
+const launchesNavItemSource = `import {
+ defineNavigationMenuItem,
+ NavigationMenuItemType,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineNavigationMenuItem({
+ universalIdentifier: SCHEMA_IDS.launch.navigationMenuItems.index,
+ name: 'Launches',
+ icon: 'IconRocket',
+ color: 'orange',
+ position: 10,
+ type: NavigationMenuItemType.VIEW,
+ viewUniversalIdentifier: SCHEMA_IDS.launch.views.index,
+});
+`;
+
+const upcomingLaunchesNavItemSource = `import {
+ defineNavigationMenuItem,
+ NavigationMenuItemType,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineNavigationMenuItem({
+ universalIdentifier: SCHEMA_IDS.launch.navigationMenuItems.upcoming,
+ name: 'Upcoming launches',
+ icon: 'IconCalendarEvent',
+ color: 'sky',
+ position: 11,
+ type: NavigationMenuItemType.VIEW,
+ viewUniversalIdentifier: SCHEMA_IDS.launch.views.upcoming,
+});
+`;
+
+const pastLaunchesNavItemSource = `import {
+ defineNavigationMenuItem,
+ NavigationMenuItemType,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineNavigationMenuItem({
+ universalIdentifier: SCHEMA_IDS.launch.navigationMenuItems.past,
+ name: 'Past launches',
+ icon: 'IconHistory',
+ color: 'gray',
+ position: 12,
+ type: NavigationMenuItemType.VIEW,
+ viewUniversalIdentifier: SCHEMA_IDS.launch.views.past,
+});
+`;
+
+const rocketsNavItemSource = `import {
+ defineNavigationMenuItem,
+ NavigationMenuItemType,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineNavigationMenuItem({
+ universalIdentifier: SCHEMA_IDS.rocket.navigationMenuItems.index,
+ name: 'Rockets',
+ icon: 'IconRocket',
+ color: 'orange',
+ position: 0,
+ type: NavigationMenuItemType.VIEW,
+ viewUniversalIdentifier: SCHEMA_IDS.rocket.views.index,
+});
+`;
+
+const payloadsNavItemSource = `import {
+ defineNavigationMenuItem,
+ NavigationMenuItemType,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineNavigationMenuItem({
+ universalIdentifier: SCHEMA_IDS.payload.navigationMenuItems.index,
+ name: 'Payloads',
+ icon: 'IconSatellite',
+ color: 'purple',
+ position: 20,
+ type: NavigationMenuItemType.VIEW,
+ viewUniversalIdentifier: SCHEMA_IDS.payload.views.index,
+});
+`;
+
+const launchSitesNavItemSource = `import {
+ defineNavigationMenuItem,
+ NavigationMenuItemType,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineNavigationMenuItem({
+ universalIdentifier: SCHEMA_IDS.launchSite.navigationMenuItems.index,
+ name: 'Launch sites',
+ icon: 'IconMapPin',
+ color: 'red',
+ position: 40,
+ type: NavigationMenuItemType.VIEW,
+ viewUniversalIdentifier: SCHEMA_IDS.launchSite.views.index,
+});
+`;
+
+const rocketsViewSource = `import { defineView, ViewKey } from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineView({
+ universalIdentifier: SCHEMA_IDS.rocket.views.index,
+ name: 'All rockets',
+ objectUniversalIdentifier: SCHEMA_IDS.rocket.object,
+ icon: 'IconRocket',
+ key: ViewKey.INDEX,
+ position: 0,
+ fields: [
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.rocket.fields.name,
+ position: 0,
+ isVisible: true,
+ size: 220,
+ },
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.rocket.fields.serialNumber,
+ position: 1,
+ isVisible: true,
+ size: 160,
+ },
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.rocket.fields.status,
+ position: 2,
+ isVisible: true,
+ size: 140,
+ },
+ ],
+});
+`;
+
+const payloadsViewSource = `import { defineView, ViewKey } from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineView({
+ universalIdentifier: SCHEMA_IDS.payload.views.index,
+ name: 'Payloads',
+ objectUniversalIdentifier: SCHEMA_IDS.payload.object,
+ icon: 'IconSatellite',
+ key: ViewKey.INDEX,
+ position: 0,
+ fields: [
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.payload.fields.name,
+ position: 0,
+ isVisible: true,
+ size: 220,
+ },
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.payload.fields.payloadType,
+ position: 1,
+ isVisible: true,
+ size: 170,
+ },
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.payload.fields.status,
+ position: 2,
+ isVisible: true,
+ size: 140,
+ },
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.payload.fields.customer,
+ position: 3,
+ isVisible: true,
+ size: 180,
+ },
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.payload.fields.launch,
+ position: 4,
+ isVisible: true,
+ size: 180,
+ },
+ ],
+});
+`;
+
+const launchSitesViewSource = `import { defineView, ViewKey } from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineView({
+ universalIdentifier: SCHEMA_IDS.launchSite.views.index,
+ name: 'Launch sites',
+ objectUniversalIdentifier: SCHEMA_IDS.launchSite.object,
+ icon: 'IconMapPin',
+ key: ViewKey.INDEX,
+ position: 0,
+ fields: [
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.launchSite.fields.name,
+ position: 0,
+ isVisible: true,
+ size: 220,
+ },
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.launchSite.fields.siteCode,
+ position: 1,
+ isVisible: true,
+ size: 140,
+ },
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.launchSite.fields.siteStatus,
+ position: 2,
+ isVisible: true,
+ size: 150,
+ },
+ ],
+});
+`;
+
+const pastLaunchesViewSource = `import {
+ defineView,
+ ViewFilterOperand,
+ ViewType,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineView({
+ universalIdentifier: SCHEMA_IDS.launch.views.past,
+ name: 'Past launches',
+ objectUniversalIdentifier: SCHEMA_IDS.launch.object,
+ icon: 'IconHistory',
+ type: ViewType.TABLE,
+ position: 2,
+ filters: [
+ {
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.launch.fields.plannedLaunchAt,
+ operand: ViewFilterOperand.IS_IN_PAST,
+ value: {},
+ },
+ ],
+});
+`;
+
+const flyAgainCommandMenuItemSource = `import {
+ CommandMenuItemAvailabilityType,
+ CommandMenuItemActionType,
+ defineCommandMenuItem,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineCommandMenuItem({
+ universalIdentifier: SCHEMA_IDS.rocket.commandMenuItems.flyAgain,
+ label: 'Fly again',
+ icon: 'IconRepeat',
+ isPinned: true,
+ position: 0,
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ objectMetadataUniversalIdentifier: SCHEMA_IDS.rocket.object,
+ action: {
+ // Re-fly a reusable rocket: create a new launch record pre-linked to
+ // this rocket so the operator only has to pick mission details.
+ type: CommandMenuItemActionType.CREATE_RELATED_RECORD,
+ relationFieldMetadataUniversalIdentifier:
+ SCHEMA_IDS.rocket.fields.launches,
+ targetObjectMetadataUniversalIdentifier: SCHEMA_IDS.launch.object,
+ },
+});
+`;
+
+const scheduleLaunchFromRocketCommandMenuItemSource = `import {
+ CommandMenuItemAvailabilityType,
+ CommandMenuItemActionType,
+ defineCommandMenuItem,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineCommandMenuItem({
+ universalIdentifier: SCHEMA_IDS.rocket.commandMenuItems.scheduleLaunch,
+ label: 'Schedule launch',
+ icon: 'IconCalendarPlus',
+ isPinned: true,
+ position: 1,
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ objectMetadataUniversalIdentifier: SCHEMA_IDS.rocket.object,
+ action: {
+ type: CommandMenuItemActionType.CREATE_RELATED_RECORD,
+ relationFieldMetadataUniversalIdentifier:
+ SCHEMA_IDS.rocket.fields.launches,
+ targetObjectMetadataUniversalIdentifier: SCHEMA_IDS.launch.object,
+ },
+});
+`;
+
+const retireRocketCommandMenuItemSource = `import {
+ CommandMenuItemAvailabilityType,
+ CommandMenuItemActionType,
+ defineCommandMenuItem,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineCommandMenuItem({
+ universalIdentifier: SCHEMA_IDS.rocket.commandMenuItems.retireRocket,
+ label: 'Retire',
+ icon: 'IconPlayerPause',
+ isPinned: true,
+ position: 2,
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ objectMetadataUniversalIdentifier: SCHEMA_IDS.rocket.object,
+ action: {
+ // One-click status flip to "Retired" — no picker needed.
+ type: CommandMenuItemActionType.SET_FIELD_VALUE,
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.rocket.fields.status,
+ value: 'RETIRED',
+ },
+});
+`;
+
+const rescheduleLaunchCommandMenuItemSource = `import {
+ CommandMenuItemAvailabilityType,
+ CommandMenuItemActionType,
+ defineCommandMenuItem,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineCommandMenuItem({
+ universalIdentifier: SCHEMA_IDS.launch.commandMenuItems.rescheduleLaunch,
+ label: 'Reschedule',
+ icon: 'IconCalendarClock',
+ isPinned: true,
+ position: 1,
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ objectMetadataUniversalIdentifier: SCHEMA_IDS.launch.object,
+ action: {
+ type: CommandMenuItemActionType.EDIT_FIELD,
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.launch.fields.plannedLaunchAt,
+ },
+});
+`;
+
+const addPayloadCommandMenuItemSource = `import {
+ CommandMenuItemAvailabilityType,
+ CommandMenuItemActionType,
+ defineCommandMenuItem,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineCommandMenuItem({
+ universalIdentifier: SCHEMA_IDS.launch.commandMenuItems.addPayload,
+ label: 'Add payload',
+ icon: 'IconBox',
+ isPinned: true,
+ position: 1,
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ objectMetadataUniversalIdentifier: SCHEMA_IDS.launch.object,
+ action: {
+ type: CommandMenuItemActionType.CREATE_RELATED_RECORD,
+ relationFieldMetadataUniversalIdentifier:
+ SCHEMA_IDS.launch.fields.payloads,
+ targetObjectMetadataUniversalIdentifier: SCHEMA_IDS.payload.object,
+ },
+});
+`;
+
+const upcomingLaunchesCommandMenuItemSource = `import {
+ CommandMenuItemAvailabilityType,
+ CommandMenuItemActionType,
+ defineCommandMenuItem,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineCommandMenuItem({
+ universalIdentifier: SCHEMA_IDS.launch.commandMenuItems.upcomingLaunches,
+ label: 'Upcoming',
+ icon: 'IconCalendarEvent',
+ isPinned: true,
+ position: 2,
+ availabilityType: CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
+ objectMetadataUniversalIdentifier: SCHEMA_IDS.launch.object,
+ action: {
+ type: CommandMenuItemActionType.NAVIGATE_TO_VIEW,
+ viewUniversalIdentifier: SCHEMA_IDS.launch.views.upcoming,
+ },
+});
+`;
+
+const bookSlotCommandMenuItemSource = `import {
+ CommandMenuItemAvailabilityType,
+ CommandMenuItemActionType,
+ defineCommandMenuItem,
+ STANDARD_OBJECT,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineCommandMenuItem({
+ universalIdentifier: SCHEMA_IDS.payload.commandMenuItems.bookSlot,
+ label: 'Book slot',
+ icon: 'IconCalendarPlus',
+ isPinned: true,
+ position: 0,
+ availabilityType: CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
+ objectMetadataUniversalIdentifier: SCHEMA_IDS.payload.object,
+ action: {
+ // Book a payload slot for a customer: create a payload pre-linked to
+ // a Company so sales can capture a booking in one step.
+ type: CommandMenuItemActionType.CREATE_RELATED_RECORD,
+ relationFieldMetadataUniversalIdentifier:
+ SCHEMA_IDS.payload.fields.customer,
+ targetObjectMetadataUniversalIdentifier:
+ STANDARD_OBJECT.company.universalIdentifier,
+ },
+});
+`;
+
+const setPayloadStatusCommandMenuItemSource = `import {
+ CommandMenuItemAvailabilityType,
+ CommandMenuItemActionType,
+ defineCommandMenuItem,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineCommandMenuItem({
+ universalIdentifier: SCHEMA_IDS.payload.commandMenuItems.setPayloadStatus,
+ label: 'Set status',
+ icon: 'IconFlag',
+ isPinned: true,
+ position: 1,
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ objectMetadataUniversalIdentifier: SCHEMA_IDS.payload.object,
+ action: {
+ type: CommandMenuItemActionType.EDIT_FIELD,
+ fieldMetadataUniversalIdentifier: SCHEMA_IDS.payload.fields.status,
+ },
+});
+`;
+
+const setCustomerStatusCommandMenuItemSource = `import {
+ CommandMenuItemAvailabilityType,
+ CommandMenuItemActionType,
+ defineCommandMenuItem,
+ STANDARD_OBJECT,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineCommandMenuItem({
+ universalIdentifier: SCHEMA_IDS.company.commandMenuItems.setCustomerStatus,
+ label: 'Set status',
+ icon: 'IconFlag',
+ isPinned: true,
+ position: 0,
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ objectMetadataUniversalIdentifier:
+ STANDARD_OBJECT.company.universalIdentifier,
+ action: {
+ type: CommandMenuItemActionType.EDIT_FIELD,
+ fieldMetadataUniversalIdentifier:
+ STANDARD_OBJECT.company.fields.accountStatus,
+ },
+});
+`;
+
+const setSiteStatusCommandMenuItemSource = `import {
+ CommandMenuItemAvailabilityType,
+ CommandMenuItemActionType,
+ defineCommandMenuItem,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineCommandMenuItem({
+ universalIdentifier: SCHEMA_IDS.launchSite.commandMenuItems.setSiteStatus,
+ label: 'Set status',
+ icon: 'IconFlag',
+ isPinned: true,
+ position: 0,
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ objectMetadataUniversalIdentifier: SCHEMA_IDS.launchSite.object,
+ action: {
+ type: CommandMenuItemActionType.EDIT_FIELD,
+ fieldMetadataUniversalIdentifier:
+ SCHEMA_IDS.launchSite.fields.siteStatus,
+ },
+});
+`;
+
+const bookWindowCommandMenuItemSource = `import {
+ CommandMenuItemAvailabilityType,
+ CommandMenuItemActionType,
+ defineCommandMenuItem,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineCommandMenuItem({
+ universalIdentifier: SCHEMA_IDS.launchSite.commandMenuItems.bookWindow,
+ label: 'Book window',
+ icon: 'IconCalendarPlus',
+ isPinned: true,
+ position: 1,
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ objectMetadataUniversalIdentifier: SCHEMA_IDS.launchSite.object,
+ action: {
+ // Reserve a launch window on this pad: creates a linked Launch with
+ // only the plannedLaunchAt to fill in.
+ type: CommandMenuItemActionType.CREATE_RELATED_RECORD,
+ relationFieldMetadataUniversalIdentifier:
+ SCHEMA_IDS.launchSite.fields.launches,
+ targetObjectMetadataUniversalIdentifier: SCHEMA_IDS.launch.object,
+ },
+});
+`;
+
+const launchesFromSiteCommandMenuItemSource = `import {
+ CommandMenuItemAvailabilityType,
+ CommandMenuItemActionType,
+ defineCommandMenuItem,
+} from 'twenty-sdk';
+
+import { SCHEMA_IDS } from 'src/constants/schema-identifiers';
+
+export default defineCommandMenuItem({
+ universalIdentifier: SCHEMA_IDS.launchSite.commandMenuItems.launchesFromSite,
+ label: 'Launches',
+ icon: 'IconRocket',
+ isPinned: true,
+ position: 2,
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ objectMetadataUniversalIdentifier: SCHEMA_IDS.launchSite.object,
+ action: {
+ type: CommandMenuItemActionType.NAVIGATE_TO_RELATED_VIEW,
+ relationFieldMetadataUniversalIdentifier:
+ SCHEMA_IDS.launchSite.fields.launches,
+ },
+});
+`;
+
+const applicationConfigSource = `import { defineApplication } from 'twenty-sdk';
+
+import {
+ APP_DESCRIPTION,
+ APP_DISPLAY_NAME,
+ APPLICATION_UNIVERSAL_IDENTIFIER,
+ DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
+} from 'src/constants/universal-identifiers';
+
+export default defineApplication({
+ universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
+ displayName: APP_DISPLAY_NAME,
+ description: APP_DESCRIPTION,
+ defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
+});
+`;
+
+export const EDITOR_FILES: ReadonlyArray = [
+ {
+ id: 'schema-identifiers',
+ name: 'schema-identifiers.ts',
+ path: 'src/constants/schema-identifiers.ts',
+ source: schemaIdentifiersSource,
+ },
+ {
+ id: 'rocket-object',
+ name: 'rocket.object.ts',
+ path: 'src/objects/rocket.object.ts',
+ source: rocketObjectSource,
+ },
+ {
+ id: 'launch-object',
+ name: 'launch.object.ts',
+ path: 'src/objects/launch.object.ts',
+ source: launchObjectSource,
+ },
+ {
+ id: 'payload-object',
+ name: 'payload.object.ts',
+ path: 'src/objects/payload.object.ts',
+ source: payloadObjectSource,
+ },
+ {
+ id: 'launch-site-object',
+ name: 'launch-site.object.ts',
+ path: 'src/objects/launch-site.object.ts',
+ source: launchSiteObjectSource,
+ },
+ {
+ id: 'view-launches',
+ name: 'launches.view.ts',
+ path: 'src/views/launches.view.ts',
+ source: launchesViewSource,
+ },
+ {
+ id: 'view-upcoming',
+ name: 'upcoming-launches.view.ts',
+ path: 'src/views/upcoming-launches.view.ts',
+ source: upcomingLaunchesViewSource,
+ },
+ {
+ id: 'nav-launches',
+ name: 'launches.navigation-menu-item.ts',
+ path: 'src/navigation-menu-items/launches.navigation-menu-item.ts',
+ source: launchesNavItemSource,
+ },
+ {
+ id: 'nav-upcoming',
+ name: 'upcoming-launches.navigation-menu-item.ts',
+ path: 'src/navigation-menu-items/upcoming-launches.navigation-menu-item.ts',
+ source: upcomingLaunchesNavItemSource,
+ },
+ {
+ id: 'nav-past-launches',
+ name: 'past-launches.navigation-menu-item.ts',
+ path: 'src/navigation-menu-items/past-launches.navigation-menu-item.ts',
+ source: pastLaunchesNavItemSource,
+ },
+ {
+ id: 'nav-rockets',
+ name: 'rockets.navigation-menu-item.ts',
+ path: 'src/navigation-menu-items/rockets.navigation-menu-item.ts',
+ source: rocketsNavItemSource,
+ },
+ {
+ id: 'nav-payloads',
+ name: 'payloads.navigation-menu-item.ts',
+ path: 'src/navigation-menu-items/payloads.navigation-menu-item.ts',
+ source: payloadsNavItemSource,
+ },
+ {
+ id: 'nav-launch-sites',
+ name: 'launch-sites.navigation-menu-item.ts',
+ path: 'src/navigation-menu-items/launch-sites.navigation-menu-item.ts',
+ source: launchSitesNavItemSource,
+ },
+ {
+ id: 'view-rockets',
+ name: 'rockets.view.ts',
+ path: 'src/views/rockets.view.ts',
+ source: rocketsViewSource,
+ },
+ {
+ id: 'view-payloads',
+ name: 'payloads.view.ts',
+ path: 'src/views/payloads.view.ts',
+ source: payloadsViewSource,
+ },
+ {
+ id: 'view-launch-sites',
+ name: 'launch-sites.view.ts',
+ path: 'src/views/launch-sites.view.ts',
+ source: launchSitesViewSource,
+ },
+ {
+ id: 'view-past-launches',
+ name: 'past-launches.view.ts',
+ path: 'src/views/past-launches.view.ts',
+ source: pastLaunchesViewSource,
+ },
+ {
+ id: 'cmd-fly-again',
+ name: 'fly-again.command-menu-item.ts',
+ path: 'src/command-menu-items/fly-again.command-menu-item.ts',
+ source: flyAgainCommandMenuItemSource,
+ },
+ {
+ id: 'cmd-schedule-launch',
+ name: 'schedule-launch.command-menu-item.ts',
+ path: 'src/command-menu-items/schedule-launch.command-menu-item.ts',
+ source: scheduleLaunchFromRocketCommandMenuItemSource,
+ },
+ {
+ id: 'cmd-retire-rocket',
+ name: 'retire-rocket.command-menu-item.ts',
+ path: 'src/command-menu-items/retire-rocket.command-menu-item.ts',
+ source: retireRocketCommandMenuItemSource,
+ },
+ {
+ id: 'cmd-reschedule-launch',
+ name: 'reschedule-launch.command-menu-item.ts',
+ path: 'src/command-menu-items/reschedule-launch.command-menu-item.ts',
+ source: rescheduleLaunchCommandMenuItemSource,
+ },
+ {
+ id: 'cmd-add-payload',
+ name: 'add-payload.command-menu-item.ts',
+ path: 'src/command-menu-items/add-payload.command-menu-item.ts',
+ source: addPayloadCommandMenuItemSource,
+ },
+ {
+ id: 'cmd-upcoming-launches',
+ name: 'upcoming-launches.command-menu-item.ts',
+ path: 'src/command-menu-items/upcoming-launches.command-menu-item.ts',
+ source: upcomingLaunchesCommandMenuItemSource,
+ },
+ {
+ id: 'cmd-book-slot',
+ name: 'book-slot.command-menu-item.ts',
+ path: 'src/command-menu-items/book-slot.command-menu-item.ts',
+ source: bookSlotCommandMenuItemSource,
+ },
+ {
+ id: 'cmd-set-payload-status',
+ name: 'set-payload-status.command-menu-item.ts',
+ path: 'src/command-menu-items/set-payload-status.command-menu-item.ts',
+ source: setPayloadStatusCommandMenuItemSource,
+ },
+ {
+ id: 'cmd-set-customer-status',
+ name: 'set-customer-status.command-menu-item.ts',
+ path: 'src/command-menu-items/set-customer-status.command-menu-item.ts',
+ source: setCustomerStatusCommandMenuItemSource,
+ },
+ {
+ id: 'cmd-set-site-status',
+ name: 'set-site-status.command-menu-item.ts',
+ path: 'src/command-menu-items/set-site-status.command-menu-item.ts',
+ source: setSiteStatusCommandMenuItemSource,
+ },
+ {
+ id: 'cmd-book-window',
+ name: 'book-window.command-menu-item.ts',
+ path: 'src/command-menu-items/book-window.command-menu-item.ts',
+ source: bookWindowCommandMenuItemSource,
+ },
+ {
+ id: 'cmd-launches-from-site',
+ name: 'launches-from-site.command-menu-item.ts',
+ path: 'src/command-menu-items/launches-from-site.command-menu-item.ts',
+ source: launchesFromSiteCommandMenuItemSource,
+ },
+ {
+ id: 'application-config',
+ name: 'application-config.ts',
+ path: 'src/application-config.ts',
+ source: applicationConfigSource,
+ },
+];
+
+export const DEFAULT_EDITOR_FILE_ID = 'launch-object';
+
+// File shown by default before the chat scaffolds any project files.
+export const STARTER_EDITOR_FILE_ID = 'application-config';
+
+// Files (and explorer rows) that only exist after the chat scaffolds the
+// launch-ops CRM. Used to filter the explorer tree / tabs before generation.
+export const GENERATED_FILE_IDS: ReadonlySet = new Set([
+ 'schema-identifiers',
+ 'rocket-object',
+ 'launch-object',
+ 'payload-object',
+ 'launch-site-object',
+ 'view-rockets',
+ 'view-launches',
+ 'view-upcoming',
+ 'view-past-launches',
+ 'view-payloads',
+ 'view-launch-sites',
+ 'nav-rockets',
+ 'nav-launches',
+ 'nav-upcoming',
+ 'nav-past-launches',
+ 'nav-payloads',
+ 'nav-launch-sites',
+ 'cmd-fly-again',
+ 'cmd-schedule-launch',
+ 'cmd-retire-rocket',
+ 'cmd-reschedule-launch',
+ 'cmd-add-payload',
+ 'cmd-upcoming-launches',
+ 'cmd-book-slot',
+ 'cmd-set-payload-status',
+ 'cmd-set-customer-status',
+ 'cmd-set-site-status',
+ 'cmd-book-window',
+ 'cmd-launches-from-site',
+]);
+
+export const EXPLORER_NODES: ExplorerNode[] = [
+ {
+ id: 'root',
+ name: 'my-twenty-app',
+ depth: 0,
+ kind: 'folder',
+ expanded: true,
+ },
+ { id: 'github', name: '.github', depth: 1, kind: 'folder', expanded: false },
+ { id: 'src', name: 'src', depth: 1, kind: 'folder', expanded: true },
+ { id: 'tests', name: '__tests__', depth: 2, kind: 'folder', expanded: false },
+ { id: 'agents', name: 'agents', depth: 2, kind: 'folder', expanded: false },
+ {
+ id: 'command-menu-items',
+ name: 'command-menu-items',
+ depth: 2,
+ kind: 'folder',
+ expanded: true,
+ },
+ {
+ id: 'file-cmd-add-payload',
+ name: 'add-payload.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-add-payload',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-book-slot',
+ name: 'book-slot.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-book-slot',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-book-window',
+ name: 'book-window.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-book-window',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-fly-again',
+ name: 'fly-again.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-fly-again',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-launches-from-site',
+ name: 'launches-from-site.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-launches-from-site',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-reschedule-launch',
+ name: 'reschedule-launch.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-reschedule-launch',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-retire-rocket',
+ name: 'retire-rocket.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-retire-rocket',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-schedule-launch',
+ name: 'schedule-launch.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-schedule-launch',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-set-customer-status',
+ name: 'set-customer-status.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-set-customer-status',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-set-payload-status',
+ name: 'set-payload-status.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-set-payload-status',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-set-site-status',
+ name: 'set-site-status.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-set-site-status',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-upcoming-launches',
+ name: 'upcoming-launches.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-upcoming-launches',
+ generated: true,
+ },
+ {
+ id: 'constants',
+ name: 'constants',
+ depth: 2,
+ kind: 'folder',
+ expanded: true,
+ },
+ {
+ id: 'file-schema-identifiers',
+ name: 'schema-identifiers.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'schema-identifiers',
+ generated: true,
+ },
+ { id: 'fields', name: 'fields', depth: 2, kind: 'folder', expanded: false },
+ {
+ id: 'front-components',
+ name: 'front-components',
+ depth: 2,
+ kind: 'folder',
+ expanded: false,
+ },
+ {
+ id: 'logic-functions',
+ name: 'logic-functions',
+ depth: 2,
+ kind: 'folder',
+ expanded: false,
+ },
+ {
+ id: 'navigation-menu-items',
+ name: 'navigation-menu-items',
+ depth: 2,
+ kind: 'folder',
+ expanded: true,
+ },
+ {
+ id: 'file-nav-launch-sites',
+ name: 'launch-sites.navigation-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'nav-launch-sites',
+ generated: true,
+ },
+ {
+ id: 'file-nav-launches',
+ name: 'launches.navigation-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'nav-launches',
+ generated: true,
+ },
+ {
+ id: 'file-nav-past-launches',
+ name: 'past-launches.navigation-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'nav-past-launches',
+ generated: true,
+ },
+ {
+ id: 'file-nav-payloads',
+ name: 'payloads.navigation-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'nav-payloads',
+ generated: true,
+ },
+ {
+ id: 'file-nav-rockets',
+ name: 'rockets.navigation-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'nav-rockets',
+ generated: true,
+ },
+ {
+ id: 'file-nav-upcoming',
+ name: 'upcoming-launches.navigation-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'nav-upcoming',
+ generated: true,
+ },
+ { id: 'objects', name: 'objects', depth: 2, kind: 'folder', expanded: true },
+ {
+ id: 'file-launch-site-object',
+ name: 'launch-site.object.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'launch-site-object',
+ generated: true,
+ },
+ {
+ id: 'file-launch-object',
+ name: 'launch.object.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'launch-object',
+ generated: true,
+ },
+ {
+ id: 'file-payload-object',
+ name: 'payload.object.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'payload-object',
+ generated: true,
+ },
+ {
+ id: 'file-rocket-object',
+ name: 'rocket.object.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'rocket-object',
+ generated: true,
+ },
+ {
+ id: 'page-layouts',
+ name: 'page-layouts',
+ depth: 2,
+ kind: 'folder',
+ expanded: false,
+ },
+ { id: 'roles', name: 'roles', depth: 2, kind: 'folder', expanded: false },
+ { id: 'skills', name: 'skills', depth: 2, kind: 'folder', expanded: false },
+ { id: 'views', name: 'views', depth: 2, kind: 'folder', expanded: true },
+ {
+ id: 'file-view-launch-sites',
+ name: 'launch-sites.view.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'view-launch-sites',
+ generated: true,
+ },
+ {
+ id: 'file-view-launches',
+ name: 'launches.view.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'view-launches',
+ generated: true,
+ },
+ {
+ id: 'file-view-past-launches',
+ name: 'past-launches.view.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'view-past-launches',
+ generated: true,
+ },
+ {
+ id: 'file-view-payloads',
+ name: 'payloads.view.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'view-payloads',
+ generated: true,
+ },
+ {
+ id: 'file-view-rockets',
+ name: 'rockets.view.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'view-rockets',
+ generated: true,
+ },
+ {
+ id: 'file-view-upcoming',
+ name: 'upcoming-launches.view.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'view-upcoming',
+ generated: true,
+ },
+ {
+ id: 'application-config',
+ name: 'application-config.ts',
+ depth: 1,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'application-config',
+ },
+ {
+ id: 'gitignore',
+ name: '.gitignore',
+ depth: 1,
+ kind: 'file',
+ icon: 'git',
+ iconLabel: 'GI',
+ },
+ {
+ id: 'nvmrc',
+ name: '.nvmrc',
+ depth: 1,
+ kind: 'file',
+ icon: 'cf',
+ iconLabel: 'CF',
+ },
+ {
+ id: 'oxlintrc',
+ name: '.oxlintrc.json',
+ depth: 1,
+ kind: 'file',
+ icon: 'js',
+ iconLabel: 'JS',
+ },
+ {
+ id: 'yarnrc',
+ name: '.yarnrc.yml',
+ depth: 1,
+ kind: 'file',
+ icon: 'yaml',
+ iconLabel: 'YM',
+ },
+ {
+ id: 'llms',
+ name: 'LLMS.md',
+ depth: 1,
+ kind: 'file',
+ icon: 'md',
+ iconLabel: 'MD',
+ },
+ {
+ id: 'package',
+ name: 'package.json',
+ depth: 1,
+ kind: 'file',
+ icon: 'js',
+ iconLabel: 'JS',
+ },
+ {
+ id: 'readme',
+ name: 'README.md',
+ depth: 1,
+ kind: 'file',
+ icon: 'md',
+ iconLabel: 'MD',
+ },
+ {
+ id: 'tsconfig',
+ name: 'tsconfig.json',
+ depth: 1,
+ kind: 'file',
+ icon: 'js',
+ iconLabel: 'JS',
+ },
+ {
+ id: 'tsconfig-spec',
+ name: 'tsconfig.spec.json',
+ depth: 1,
+ kind: 'file',
+ icon: 'js',
+ iconLabel: 'JS',
+ },
+ {
+ id: 'vitest-config',
+ name: 'vitest.config.ts',
+ depth: 1,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ },
+ {
+ id: 'yarn-lock',
+ name: 'yarn.lock',
+ depth: 1,
+ kind: 'file',
+ icon: 'lock',
+ iconLabel: 'LO',
+ },
+];
+
+// -- Tokenizer --
+
+const KEYWORDS = new Set([
+ 'import',
+ 'export',
+ 'default',
+ 'const',
+ 'let',
+ 'var',
+ 'from',
+ 'return',
+ 'if',
+ 'else',
+ 'function',
+ 'enum',
+ 'class',
+ 'type',
+ 'interface',
+ 'extends',
+ 'implements',
+ 'true',
+ 'false',
+ 'null',
+ 'undefined',
+ 'as',
+ 'new',
+ 'this',
+ 'typeof',
+ 'async',
+ 'await',
+ 'for',
+ 'of',
+ 'in',
+ 'switch',
+ 'case',
+ 'break',
+ 'continue',
+ 'throw',
+]);
+
+const tokenizeLine = (line: string): CodeLine => {
+ const tokens: CodeToken[] = [];
+ let textBuffer = '';
+ let i = 0;
+
+ const flushText = () => {
+ if (textBuffer.length > 0) {
+ tokens.push({ kind: 'text', value: textBuffer });
+ textBuffer = '';
+ }
+ };
+
+ const push = (token: CodeToken) => {
+ flushText();
+ tokens.push(token);
+ };
+
+ while (i < line.length) {
+ const ch = line[i];
+
+ if (ch === '/' && line[i + 1] === '/') {
+ push({ kind: 'comment', value: line.slice(i) });
+ i = line.length;
+ continue;
+ }
+
+ if (ch === "'" || ch === '"' || ch === '`') {
+ const quote = ch;
+ let j = i + 1;
+ while (j < line.length) {
+ if (line[j] === '\\') {
+ j += 2;
+ continue;
+ }
+ if (line[j] === quote) {
+ break;
+ }
+ j += 1;
+ }
+ push({ kind: 'string', value: line.slice(i, j + 1) });
+ i = j + 1;
+ continue;
+ }
+
+ if (/[a-zA-Z_$]/.test(ch)) {
+ let j = i + 1;
+ while (j < line.length && /[\w$]/.test(line[j])) {
+ j += 1;
+ }
+ const word = line.slice(i, j);
+
+ if (KEYWORDS.has(word)) {
+ push({ kind: 'keyword', value: word });
+ } else if (/^[A-Z]/.test(word)) {
+ push({ kind: 'identifier', value: word });
+ } else {
+ // Look past whitespace to decide between function / property / text.
+ let k = j;
+ while (k < line.length && line[k] === ' ') {
+ k += 1;
+ }
+ if (line[k] === '(') {
+ push({ kind: 'function', value: word });
+ } else if (line[k] === ':') {
+ push({ kind: 'property', value: word });
+ } else {
+ textBuffer += word;
+ }
+ }
+
+ i = j;
+ continue;
+ }
+
+ textBuffer += ch;
+ i += 1;
+ }
+
+ flushText();
+ return tokens;
+};
+
+export const tokenizeSource = (source: string): CodeLine[] =>
+ source.split('\n').map(tokenizeLine);
+
+export const findFileById = (fileId: string): EditorFile | undefined =>
+ EDITOR_FILES.find((file) => file.id === fileId);
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/editorTokens.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/editorTokens.ts
new file mode 100644
index 00000000000..783e4168384
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/editorTokens.ts
@@ -0,0 +1,54 @@
+// Dark-theme tokens for the Terminal's Editor view. Mirrors the Figma mock
+// (node 4126:54062).
+export const EDITOR_TOKENS = {
+ surface: {
+ topBar: '#111216',
+ topBarBorder: 'rgba(255, 255, 255, 0.08)',
+ body: '#111216',
+ sidebar: '#0b0b0d',
+ sidebarBorder: 'rgba(255, 255, 255, 0.06)',
+ explorerHeaderBorder: 'rgba(255, 255, 255, 0.05)',
+ tabBar: '#141416',
+ tabBarBorder: 'rgba(255, 255, 255, 0.06)',
+ activeTab: '#111216',
+ activeTabAccent: '#598ffa',
+ activeRow: 'rgba(36, 87, 161, 0.28)',
+ toggleBackground: '#1e1f24',
+ toggleBorder: 'rgba(255, 255, 255, 0.08)',
+ activeSegmentBackground: '#2b2d35',
+ activeSegmentBorder: 'rgba(255, 255, 255, 0.08)',
+ indentGuide: 'rgba(255, 255, 255, 0.05)',
+ },
+ text: {
+ primary: 'rgba(255, 255, 255, 0.96)',
+ active: '#ebebf0',
+ secondary: 'rgba(255, 255, 255, 0.82)',
+ muted: 'rgba(255, 255, 255, 0.7)',
+ dim: 'rgba(255, 255, 255, 0.6)',
+ explorerLabel: 'rgba(255, 255, 255, 0.45)',
+ caret: 'rgba(255, 255, 255, 0.4)',
+ tabAccent: '#598ffa',
+ gutter: '#45454d',
+ code: '#bec2c9',
+ },
+ syntax: {
+ keyword: '#c397f5',
+ function: '#82aaff',
+ string: '#a5d6a7',
+ property: '#f07178',
+ identifier: '#f5c78a',
+ comment: 'rgba(255, 255, 255, 0.42)',
+ },
+ fileIcon: {
+ ts: 'rgba(59, 140, 245, 0.85)',
+ md: 'rgba(110, 186, 245, 0.85)',
+ js: 'rgba(237, 184, 79, 0.85)',
+ git: 'rgba(229, 115, 77, 0.85)',
+ yaml: 'rgba(217, 122, 107, 0.85)',
+ cf: 'rgba(140, 140, 153, 0.85)',
+ lock: 'rgba(140, 140, 153, 0.85)',
+ },
+ shadow: {
+ activeSegment: '0 1px 4px 0 rgba(0, 0, 0, 0.28)',
+ },
+} as const;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPromptBox.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPromptBox.tsx
new file mode 100644
index 00000000000..35e80f0c096
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPromptBox.tsx
@@ -0,0 +1,282 @@
+'use client';
+
+import { IconFolder, IconGitBranch } from '@tabler/icons-react';
+import { styled } from '@linaria/react';
+import { useEffect, useState } from 'react';
+import { TERMINAL_TOKENS } from './terminalTokens';
+import { TerminalPromptChip } from './TerminalPromptChip';
+import { TerminalSendButton } from './TerminalSendButton';
+import { TRAFFIC_LIGHTS_ESCAPE_EVENT } from './TerminalTrafficLights';
+
+// After this many clicks on the "Ask anything…" placeholder the dormant
+// traffic-light dots detach and fly off both windows — the final easter egg.
+const TRAFFIC_LIGHTS_ESCAPE_THRESHOLD = 5;
+
+// Playful placeholders cycled through when a curious visitor clicks the
+// "Ask anything…" placeholder after the first demo chat finishes. The goal is
+// delight without derailing — nothing that looks like a real prompt response.
+const EASTER_EGG_MESSAGES = [
+ 'Ask me to do something your CRM should have done years ago',
+ 'Build the thing your admin said was impossible',
+ 'Turn this CRM into something actually useful',
+ 'Ask for a workflow. Not a miracle.',
+ 'Describe the app you wish you already had',
+ 'Create a spaceship. Or a sales workflow.',
+ 'Make Salesforce nervous',
+ 'Still here? Type the impossible',
+ 'Describe the tool you were not supposed to have.',
+ 'Build the thing hidden behind a paywall elsewhere.',
+];
+
+const PromptArea = styled.div`
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ padding: 12px;
+ width: 100%;
+`;
+
+const PromptBox = styled.div`
+ background: ${TERMINAL_TOKENS.surface.promptBoxBackground};
+ border: 1px solid ${TERMINAL_TOKENS.surface.promptBoxBorder};
+ border-radius: 16px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ justify-content: space-between;
+ min-height: 120px;
+ overflow: hidden;
+ padding: 12px;
+ transition:
+ background-color 0.16s ease,
+ border-color 0.16s ease;
+ width: 100%;
+
+ &:hover {
+ background: ${TERMINAL_TOKENS.surface.promptBoxBackgroundHover};
+ border-color: ${TERMINAL_TOKENS.surface.promptBoxBorderFocus};
+ }
+
+ &:focus-within {
+ background: ${TERMINAL_TOKENS.surface.promptBoxBackgroundHover};
+ border-color: ${TERMINAL_TOKENS.surface.promptBoxBorderFocus};
+ }
+
+ &[data-wiggle='true'] {
+ animation: promptWiggle 0.5s ease;
+ }
+
+ @keyframes promptWiggle {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+ 20% {
+ transform: translateX(-3px) rotate(-0.4deg);
+ }
+ 40% {
+ transform: translateX(3px) rotate(0.4deg);
+ }
+ 60% {
+ transform: translateX(-2px) rotate(-0.2deg);
+ }
+ 80% {
+ transform: translateX(2px) rotate(0.2deg);
+ }
+ }
+`;
+
+const PromptTextRow = styled.div<{ $clickable?: boolean }>`
+ align-items: flex-start;
+ cursor: ${({ $clickable }) => ($clickable ? 'pointer' : 'default')};
+ display: flex;
+ flex: 1 1 auto;
+ min-width: 0;
+ padding-left: 6px;
+ user-select: none;
+ -webkit-user-select: none;
+`;
+
+const PromptText = styled.p<{ $isPlaceholder?: boolean }>`
+ color: ${({ $isPlaceholder }) =>
+ $isPlaceholder ? TERMINAL_TOKENS.text.muted : TERMINAL_TOKENS.text.prompt};
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ font-size: 13px;
+ font-weight: 400;
+ line-height: 18px;
+ margin: 0;
+ overflow-wrap: anywhere;
+ transition: color 0.18s ease;
+ white-space: normal;
+ word-break: break-word;
+
+ animation: promptTextSwap 0.28s ease;
+
+ @keyframes promptTextSwap {
+ 0% {
+ opacity: 0;
+ transform: translateY(4px);
+ filter: blur(2px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ filter: blur(0);
+ }
+ }
+`;
+
+// flex-wrap stays nowrap so narrow footers shrink the folder chip's label
+// with an ellipsis instead of pushing ActionRow (Send button) to a new line.
+const PromptFooter = styled.div`
+ align-items: center;
+ display: flex;
+ flex-wrap: nowrap;
+ gap: 8px;
+ width: 100%;
+`;
+
+// flex: 1 1 auto + min-width: 0 lets this row shrink with the first chip
+// (folder path) rather than push Mythos/Send to the next line.
+const ChipRow = styled.div`
+ align-items: center;
+ display: flex;
+ flex: 1 1 auto;
+ gap: 8px;
+ min-width: 0;
+`;
+
+// margin-left: auto keeps the Send button anchored to the right edge whether
+// the footer fits on one line or wraps. Without this, wrapping starts each
+// new line flush-left, pushing the blue Send to the bottom-left.
+const ActionRow = styled.div`
+ align-items: center;
+ display: flex;
+ gap: 4px;
+ margin-left: auto;
+`;
+
+const MythosButton = styled.button`
+ align-items: center;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: ${TERMINAL_TOKENS.text.muted};
+ cursor: pointer;
+ display: flex;
+ font-family: ${TERMINAL_TOKENS.font.mono};
+ font-size: 13px;
+ font-weight: 400;
+ gap: 4px;
+ height: 24px;
+ line-height: 1.4;
+ padding: 0 8px;
+ transition:
+ background-color 0.14s ease,
+ color 0.14s ease;
+ white-space: nowrap;
+
+ &:hover {
+ background: ${TERMINAL_TOKENS.surface.mythosHoverBackground};
+ color: ${TERMINAL_TOKENS.text.mutedHover};
+ }
+`;
+
+type TerminalPromptBoxProps = {
+ promptText: string;
+ promptIsPlaceholder?: boolean;
+ onSend?: () => void;
+ sendDisabled?: boolean;
+ isChatFinished?: boolean;
+ onReset?: () => void;
+};
+
+export const TerminalPromptBox = ({
+ promptText,
+ promptIsPlaceholder,
+ onSend,
+ sendDisabled,
+ isChatFinished,
+ onReset,
+}: TerminalPromptBoxProps) => {
+ const [easterEggIndex, setEasterEggIndex] = useState(null);
+ const [isWiggling, setIsWiggling] = useState(false);
+ const [, setClickCount] = useState(0);
+
+ // Reset the easter egg whenever the conversation is reset so the next demo
+ // viewer starts from the normal placeholder again.
+ useEffect(() => {
+ if (!isChatFinished) {
+ setEasterEggIndex(null);
+ setIsWiggling(false);
+ setClickCount(0);
+ }
+ }, [isChatFinished]);
+
+ const handleEasterEggClick = () => {
+ if (!isChatFinished) {
+ return;
+ }
+ setEasterEggIndex((prev) =>
+ prev === null ? 0 : (prev + 1) % EASTER_EGG_MESSAGES.length,
+ );
+ setIsWiggling(true);
+ window.setTimeout(() => setIsWiggling(false), 500);
+
+ setClickCount((prev) => {
+ const next = prev + 1;
+ // Fire the escape event every time the threshold is crossed so the
+ // effect is repeatable — each fresh streak of five clicks re-flings
+ // the dots.
+ if (next >= TRAFFIC_LIGHTS_ESCAPE_THRESHOLD) {
+ window.dispatchEvent(new CustomEvent(TRAFFIC_LIGHTS_ESCAPE_EVENT));
+ return 0;
+ }
+ return next;
+ });
+ };
+
+ const showEasterEgg = isChatFinished && easterEggIndex !== null;
+ const displayText = showEasterEgg
+ ? EASTER_EGG_MESSAGES[easterEggIndex]
+ : promptText;
+
+ return (
+
+
+
+
+ {displayText}
+
+
+
+
+ }
+ label="~/code/my-twenty-app"
+ />
+ }
+ label="main"
+ />
+
+
+ Mythos
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPromptChip.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPromptChip.tsx
new file mode 100644
index 00000000000..166f8dddaf3
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPromptChip.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import type { ReactNode } from 'react';
+import { TERMINAL_TOKENS } from './terminalTokens';
+
+type TerminalPromptChipProps = {
+ icon: ReactNode;
+ label: string;
+};
+
+// min-width: 0 lets the chip shrink below its intrinsic content width so the
+// inner label can ellipsize rather than forcing the footer to wrap.
+const ChipRoot = styled.button`
+ align-items: center;
+ background: ${TERMINAL_TOKENS.surface.chipBackground};
+ border: 1px solid ${TERMINAL_TOKENS.surface.chipBorder};
+ border-radius: 4px;
+ color: ${TERMINAL_TOKENS.text.chip};
+ cursor: pointer;
+ display: flex;
+ flex-shrink: 1;
+ font-family: ${TERMINAL_TOKENS.font.mono};
+ font-size: 13px;
+ font-weight: 500;
+ gap: 4px;
+ height: 24px;
+ line-height: 1;
+ min-width: 0;
+ padding: 4px 8px;
+ transition:
+ background-color 0.14s ease,
+ border-color 0.14s ease;
+ white-space: nowrap;
+
+ &:hover {
+ background: ${TERMINAL_TOKENS.surface.chipHoverBackground};
+ }
+`;
+
+const ChipIcon = styled.span`
+ align-items: center;
+ color: currentColor;
+ display: flex;
+ flex: 0 0 auto;
+ height: 13px;
+ justify-content: center;
+ width: 13px;
+`;
+
+const ChipLabel = styled.span`
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+export const TerminalPromptChip = ({
+ icon,
+ label,
+}: TerminalPromptChipProps) => {
+ return (
+
+ {icon}
+ {label}
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalSendButton.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalSendButton.tsx
new file mode 100644
index 00000000000..de61e587b1d
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalSendButton.tsx
@@ -0,0 +1,280 @@
+'use client';
+
+import { IconArrowBackUp, IconArrowUp } from '@tabler/icons-react';
+import { styled } from '@linaria/react';
+import { useLayoutEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { TERMINAL_TOKENS } from './terminalTokens';
+
+type TerminalSendButtonProps = {
+ onClick?: () => void;
+ disabled?: boolean;
+ mode?: 'send' | 'reset';
+};
+
+const FINGER_OFFSET_RIGHT = -22;
+const FINGER_OFFSET_BOTTOM = -18;
+const FINGER_ROTATION = -21;
+const FINGER_SIZE = 51;
+
+const SendButtonWrapper = styled.span`
+ display: inline-flex;
+ position: relative;
+`;
+
+const SendButtonRoot = styled.button<{ $isReset: boolean }>`
+ align-items: center;
+ background: ${({ $isReset }) =>
+ $isReset ? '#5a5a5a' : TERMINAL_TOKENS.accent.brand};
+ border: none;
+ border-radius: 999px;
+ box-shadow: ${({ $isReset }) =>
+ $isReset
+ ? 'none'
+ : '0 0 0 1px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.12)'};
+ color: #ffffff;
+ cursor: pointer;
+ display: flex;
+ flex: 0 0 auto;
+ height: 32px;
+ justify-content: center;
+ padding: 0 4px;
+ transition:
+ background-color 0.14s ease,
+ transform 0.12s ease;
+ width: 32px;
+
+ &:hover:not(:disabled) {
+ background: ${({ $isReset }) =>
+ $isReset ? '#4c4c4c' : TERMINAL_TOKENS.accent.brandHover};
+ }
+
+ &:active:not(:disabled) {
+ transform: scale(0.94);
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+ }
+`;
+
+const FingerHint = styled.span`
+ pointer-events: none;
+ position: fixed;
+ z-index: 20;
+`;
+
+const FingerTapAnim = styled.span`
+ animation: fingerTap 1.4s ease-in-out infinite;
+ display: block;
+
+ @keyframes fingerTap {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-6px);
+ }
+ }
+`;
+
+const FingerIcon = ({ size }: { size: number }) => (
+
+);
+
+export const TerminalSendButton = ({
+ onClick,
+ disabled,
+ mode = 'send',
+}: TerminalSendButtonProps) => {
+ const isReset = mode === 'reset';
+ const [hintDismissed, setHintDismissed] = useState(false);
+ const [hintPos, setHintPos] = useState<{ left: number; top: number } | null>(
+ null,
+ );
+ const [hintReady, setHintReady] = useState(false);
+ const buttonRef = useRef(null);
+ const dismissHint = () => setHintDismissed(true);
+ const showHint = !hintDismissed && !isReset && !disabled;
+
+ useLayoutEffect(() => {
+ if (!showHint) {
+ setHintPos(null);
+ setHintReady(false);
+ return;
+ }
+ let rafId = 0;
+ let lastLeft = Number.NaN;
+ let lastTop = Number.NaN;
+ const readyTimer = window.setTimeout(() => setHintReady(true), 400);
+
+ const tick = () => {
+ rafId = 0;
+ const el = buttonRef.current;
+ if (el) {
+ const rect = el.getBoundingClientRect();
+ if (rect.width > 0) {
+ const nextLeft = rect.right + FINGER_OFFSET_RIGHT;
+ const nextTop = rect.bottom + FINGER_OFFSET_BOTTOM;
+ if (nextLeft !== lastLeft || nextTop !== lastTop) {
+ lastLeft = nextLeft;
+ lastTop = nextTop;
+ setHintPos({ left: nextLeft, top: nextTop });
+ }
+ }
+ }
+ if (!document.hidden) {
+ rafId = window.requestAnimationFrame(tick);
+ }
+ };
+
+ const start = () => {
+ if (rafId === 0 && !document.hidden) {
+ rafId = window.requestAnimationFrame(tick);
+ }
+ };
+ const handleVisibility = () => {
+ if (document.hidden) {
+ if (rafId !== 0) {
+ window.cancelAnimationFrame(rafId);
+ rafId = 0;
+ }
+ } else {
+ start();
+ }
+ };
+ start();
+ document.addEventListener('visibilitychange', handleVisibility);
+
+ const handleTerminalInteraction = (event: PointerEvent) => {
+ const btnEl = buttonRef.current;
+ if (!btnEl) return;
+ const shell = btnEl.closest('[class*="Shell"]');
+ if (shell && event.target instanceof Node && shell.contains(event.target)) {
+ setHintDismissed(true);
+ }
+ };
+ window.addEventListener('pointerdown', handleTerminalInteraction, true);
+
+ return () => {
+ if (rafId !== 0) window.cancelAnimationFrame(rafId);
+ window.clearTimeout(readyTimer);
+ document.removeEventListener('visibilitychange', handleVisibility);
+ window.removeEventListener(
+ 'pointerdown',
+ handleTerminalInteraction,
+ true,
+ );
+ };
+ }, [showHint]);
+
+ return (
+
+ {
+ dismissHint();
+ onClick?.();
+ }}
+ onMouseEnter={dismissHint}
+ ref={buttonRef}
+ type="button"
+ >
+ {isReset ? (
+
+ ) : (
+
+ )}
+
+ {showHint && hintPos && typeof document !== 'undefined'
+ ? createPortal(
+
+
+
+
+ ,
+ document.body,
+ )
+ : null}
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalToggle.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalToggle.tsx
new file mode 100644
index 00000000000..b348af08fa4
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalToggle.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import { useState } from 'react';
+import { ClaudeLogo } from './ClaudeLogo';
+import { CursorLogo } from './CursorLogo';
+import { EDITOR_TOKENS } from './TerminalEditor/editorTokens';
+import { TERMINAL_TOKENS } from './terminalTokens';
+
+export type TerminalToggleValue = 'editor' | 'ai-chat';
+
+type TerminalToggleProps = {
+ value?: TerminalToggleValue;
+ onChange?: (value: TerminalToggleValue) => void;
+ theme?: 'light' | 'dark';
+};
+
+const ToggleRoot = styled.div<{ $dark?: boolean }>`
+ align-items: center;
+ background: ${({ $dark }) =>
+ $dark
+ ? EDITOR_TOKENS.surface.toggleBackground
+ : TERMINAL_TOKENS.surface.toggleBackground};
+ border: 1px solid
+ ${({ $dark }) =>
+ $dark
+ ? EDITOR_TOKENS.surface.toggleBorder
+ : TERMINAL_TOKENS.surface.toggleBorder};
+ border-radius: 9px;
+ box-sizing: border-box;
+ display: flex;
+ gap: 2px;
+ padding: 3px;
+ transition:
+ background-color 0.2s ease,
+ border-color 0.2s ease;
+`;
+
+const SegmentButton = styled.button<{ $active?: boolean; $dark?: boolean }>`
+ align-items: center;
+ background: ${({ $active, $dark }) => {
+ if (!$active) {
+ return 'transparent';
+ }
+ return $dark
+ ? EDITOR_TOKENS.surface.activeSegmentBackground
+ : TERMINAL_TOKENS.surface.activeSegmentBackground;
+ }};
+ border: 1px solid
+ ${({ $active, $dark }) => {
+ if (!$active) {
+ return 'transparent';
+ }
+ return $dark
+ ? EDITOR_TOKENS.surface.activeSegmentBorder
+ : TERMINAL_TOKENS.surface.activeSegmentBorder;
+ }};
+ border-radius: 6px;
+ box-shadow: ${({ $active, $dark }) => {
+ if (!$active) {
+ return 'none';
+ }
+ return $dark
+ ? EDITOR_TOKENS.shadow.activeSegment
+ : TERMINAL_TOKENS.shadow.activeSegment;
+ }};
+ color: ${({ $active, $dark }) => {
+ if ($dark) {
+ return $active ? EDITOR_TOKENS.text.primary : EDITOR_TOKENS.text.dim;
+ }
+ return $active
+ ? TERMINAL_TOKENS.text.primary
+ : TERMINAL_TOKENS.text.secondary;
+ }};
+ box-sizing: border-box;
+ cursor: pointer;
+ display: flex;
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ font-size: 13px;
+ font-weight: 500;
+ gap: 6px;
+ height: 24px;
+ justify-content: center;
+ line-height: 1;
+ padding: 0 8px;
+ transition:
+ background-color 0.2s ease,
+ color 0.2s ease,
+ border-color 0.2s ease;
+ white-space: nowrap;
+
+ &:hover {
+ background: ${({ $active, $dark }) => {
+ if ($active) {
+ return $dark
+ ? EDITOR_TOKENS.surface.activeSegmentBackground
+ : TERMINAL_TOKENS.surface.activeSegmentBackground;
+ }
+ return $dark
+ ? 'rgba(255, 255, 255, 0.04)'
+ : TERMINAL_TOKENS.surface.inactiveSegmentHoverBackground;
+ }};
+ color: ${({ $dark }) =>
+ $dark ? EDITOR_TOKENS.text.primary : TERMINAL_TOKENS.text.primary};
+ }
+`;
+
+const SegmentIconWrap = styled.span`
+ align-items: center;
+ color: currentColor;
+ display: flex;
+ flex: 0 0 auto;
+ height: 14px;
+ justify-content: center;
+ width: 14px;
+`;
+
+export const TerminalToggle = ({
+ value: controlledValue,
+ onChange,
+ theme = 'light',
+}: TerminalToggleProps) => {
+ const [internalValue, setInternalValue] =
+ useState('ai-chat');
+ const value = controlledValue ?? internalValue;
+ const isDark = theme === 'dark';
+
+ const selectSegment = (nextValue: TerminalToggleValue) => () => {
+ if (controlledValue === undefined) {
+ setInternalValue(nextValue);
+ }
+ onChange?.(nextValue);
+ };
+
+ return (
+
+
+
+
+
+ Editor
+
+
+
+
+
+ AI Chat
+
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTopBar.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTopBar.tsx
new file mode 100644
index 00000000000..9edda71b8dd
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTopBar.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import type { PointerEvent as ReactPointerEvent } from 'react';
+import { DiffPill } from './TerminalDiff/DiffPill';
+import { DIFF_TOTALS } from './TerminalDiff/diffData';
+import { EDITOR_TOKENS } from './TerminalEditor/editorTokens';
+import { TERMINAL_TOKENS } from './terminalTokens';
+import { TerminalToggle, type TerminalToggleValue } from './TerminalToggle';
+import { TerminalTrafficLights } from './TerminalTrafficLights';
+
+type TerminalTopBarProps = {
+ onDragStart: (event: ReactPointerEvent) => void;
+ isDragging: boolean;
+ onZoomTripleClick?: () => void;
+ view?: TerminalToggleValue;
+ onViewChange?: (value: TerminalToggleValue) => void;
+ diffVisible?: boolean;
+ diffOpen?: boolean;
+ onToggleDiff?: () => void;
+};
+
+const TopBarRoot = styled.div<{ $isDragging: boolean; $dark?: boolean }>`
+ align-items: center;
+ background: ${({ $dark }) =>
+ $dark ? EDITOR_TOKENS.surface.topBar : 'transparent'};
+ border-bottom: 1px solid
+ ${({ $dark }) =>
+ $dark
+ ? EDITOR_TOKENS.surface.topBarBorder
+ : TERMINAL_TOKENS.surface.topBarBorder};
+ box-sizing: border-box;
+ cursor: ${({ $isDragging }) => ($isDragging ? 'grabbing' : 'grab')};
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ height: 48px;
+ padding: 0 12px;
+ transition:
+ background-color 0.2s ease,
+ border-color 0.2s ease;
+ user-select: none;
+ width: 100%;
+`;
+
+const TopRight = styled.div<{ $visible: boolean }>`
+ align-items: center;
+ display: flex;
+ justify-content: flex-end;
+ opacity: ${({ $visible }) => ($visible ? 1 : 0)};
+ pointer-events: ${({ $visible }) => ($visible ? 'auto' : 'none')};
+ transition: opacity 0.22s ease;
+`;
+
+export const TerminalTopBar = ({
+ onDragStart,
+ isDragging,
+ onZoomTripleClick,
+ view,
+ onViewChange,
+ diffVisible = false,
+ diffOpen = false,
+ onToggleDiff,
+}: TerminalTopBarProps) => {
+ const isDark = view === 'editor';
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights.tsx
new file mode 100644
index 00000000000..da96a1eb3b5
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights.tsx
@@ -0,0 +1,503 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import {
+ useEffect,
+ useRef,
+ useState,
+ type MouseEvent as ReactMouseEvent,
+} from 'react';
+import { createPortal } from 'react-dom';
+import { TERMINAL_TOKENS } from './terminalTokens';
+
+const TRAFFIC_LIGHT_DOT_SIZE = 12;
+const TRAFFIC_LIGHT_GAP = 8;
+const DEFAULT_HORIZONTAL_INSET = 6;
+
+// Global event dispatched when the placeholder easter egg crosses its
+// escalation threshold. Listened for by every TerminalTrafficLights on the
+// page so all six dots (three per window) detach and fall at once.
+export const TRAFFIC_LIGHTS_ESCAPE_EVENT = 'twenty-traffic-lights-escape';
+
+// Physics tuned for a slow, floaty fall at ~60fps. Values are pixels per
+// frame (or per frame^2 for gravity). Ground friction is intentionally low
+// so the dots slide a little before settling, which reads as weight.
+const GRAVITY = 0.55;
+const BOUNCE_DAMPING = 0.4;
+const AIR_FRICTION = 0.915;
+const GROUND_FRICTION = 0.32;
+const REST_VELOCITY = 0.9;
+const FLOOR_PADDING = 0;
+const INITIAL_POP_MIN = -5;
+const INITIAL_POP_MAX = -8;
+const INITIAL_HORIZONTAL_RANGE = 3;
+const INITIAL_SPIN = 4;
+const SCROLL_IMPULSE_MIN = 7;
+const SCROLL_IMPULSE_MAX = 12;
+const SCROLL_HORIZONTAL_RANGE = 1.5;
+
+type PhysicsState = {
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ rotation: number;
+ angularVelocity: number;
+ isResting: boolean;
+};
+
+const TrafficLightsContainer = styled.div<{ $horizontalInset: number }>`
+ align-items: center;
+ display: flex;
+ gap: ${TRAFFIC_LIGHT_GAP}px;
+ padding: 0 ${({ $horizontalInset }) => `${$horizontalInset}px`};
+`;
+
+const TrafficLightDot = styled.button<{
+ $background: string;
+ $backgroundActive: string;
+}>`
+ align-items: center;
+ background: ${({ $background }) => $background};
+ border: none;
+ border-radius: 999px;
+ cursor: pointer;
+ display: flex;
+ flex: 0 0 auto;
+ height: ${TRAFFIC_LIGHT_DOT_SIZE}px;
+ justify-content: center;
+ padding: 0;
+ position: relative;
+ transition:
+ background-color 0.12s ease,
+ transform 0.12s ease;
+ width: ${TRAFFIC_LIGHT_DOT_SIZE}px;
+
+ &::after {
+ border-radius: 999px;
+ box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.12);
+ content: '';
+ inset: 0;
+ pointer-events: none;
+ position: absolute;
+ }
+
+ svg {
+ opacity: 0;
+ transition: opacity 0.12s ease;
+ }
+
+ &:hover {
+ background: ${({ $backgroundActive }) => $backgroundActive};
+
+ svg {
+ opacity: 1;
+ }
+ }
+
+ &:active {
+ transform: scale(0.92);
+ }
+
+ /* While escaping, the originals stay in layout but are invisible — the
+ visible bouncing dots are portaled copies on document.body so they're
+ positioned relative to the viewport (not any transformed ancestor). */
+ &[data-escaping='true'] {
+ pointer-events: none;
+ visibility: hidden;
+ }
+`;
+
+// The portal layer renders each flying dot as position: fixed on document.body
+// so the physics loop's coordinates match the viewport. The dots can fall past
+// any overflow: hidden or transformed ancestor because they aren't descendants
+// of those anymore. The container carries the physics transform; the inner
+// ball carries the click-to-return pop animation so scale doesn't fight the
+// physics translate/rotate.
+const FlyingDotContainer = styled.button`
+ background: transparent;
+ border: none;
+ cursor: default;
+ display: block;
+ height: ${TRAFFIC_LIGHT_DOT_SIZE}px;
+ left: 0;
+ padding: 0;
+ pointer-events: none;
+ position: fixed;
+ top: 0;
+ width: ${TRAFFIC_LIGHT_DOT_SIZE}px;
+ z-index: 9999;
+
+ /* Catchable once the physics loop flags the dot as resting. */
+ &[data-resting='true'] {
+ cursor: pointer;
+ pointer-events: auto;
+ }
+`;
+
+const FlyingDotBall = styled.span<{
+ $background: string;
+ $backgroundActive: string;
+}>`
+ align-items: center;
+ background: ${({ $background }) => $background};
+ border-radius: 999px;
+ box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.12);
+ display: flex;
+ height: 100%;
+ justify-content: center;
+ transform-origin: center;
+ transition: background-color 0.12s ease;
+ width: 100%;
+
+ svg {
+ opacity: 0;
+ transition: opacity 0.12s ease;
+ }
+
+ /* Mirror the resting dot's hover treatment from the top bar so it feels
+ like the real Mac traffic lights even after they've fallen. */
+ [data-resting='true']:hover > & {
+ background: ${({ $backgroundActive }) => $backgroundActive};
+ }
+
+ [data-resting='true']:hover > & svg {
+ opacity: 1;
+ }
+
+ &[data-returning='true'] {
+ animation: dotPop 0.38s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
+ }
+
+ @keyframes dotPop {
+ 0% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 40% {
+ opacity: 1;
+ transform: scale(1.55);
+ }
+ 100% {
+ opacity: 0;
+ transform: scale(0);
+ }
+ }
+`;
+
+const CloseGlyph = () => (
+
+);
+
+const MinimizeGlyph = () => (
+
+);
+
+const ZoomGlyph = () => (
+
+);
+
+type TerminalTrafficLightsProps = {
+ horizontalInset?: number;
+ onZoomTripleClick?: () => void;
+};
+
+const DOT_DEFINITIONS = [
+ {
+ background: TERMINAL_TOKENS.trafficLight.close,
+ backgroundActive: TERMINAL_TOKENS.trafficLight.closeActive,
+ Glyph: CloseGlyph,
+ },
+ {
+ background: TERMINAL_TOKENS.trafficLight.minimize,
+ backgroundActive: TERMINAL_TOKENS.trafficLight.minimizeActive,
+ Glyph: MinimizeGlyph,
+ },
+ {
+ background: TERMINAL_TOKENS.trafficLight.zoom,
+ backgroundActive: TERMINAL_TOKENS.trafficLight.zoomActive,
+ Glyph: ZoomGlyph,
+ },
+];
+
+export const TerminalTrafficLights = ({
+ horizontalInset = DEFAULT_HORIZONTAL_INSET,
+ onZoomTripleClick,
+}: TerminalTrafficLightsProps) => {
+ const [isEscaping, setIsEscaping] = useState(false);
+ const [returningDots, setReturningDots] = useState([
+ false,
+ false,
+ false,
+ ]);
+ const [returnedDots, setReturnedDots] = useState([
+ false,
+ false,
+ false,
+ ]);
+ const [portalReady, setPortalReady] = useState(false);
+ const originalRefs = useRef>([
+ null,
+ null,
+ null,
+ ]);
+ const flyingRefs = useRef>([
+ null,
+ null,
+ null,
+ ]);
+ const physicsRef = useRef([]);
+
+ useEffect(() => {
+ setPortalReady(true);
+ }, []);
+
+ const handleZoomClick = (event: ReactMouseEvent) => {
+ if (event.detail === 3) {
+ onZoomTripleClick?.();
+ }
+ };
+
+ useEffect(() => {
+ const handleEscape = () => {
+ // Always reset and re-fling so each new streak of placeholder clicks
+ // triggers a fresh escape, even if dots are mid-air or already resting.
+ physicsRef.current = originalRefs.current.map((el) => {
+ const rect = el?.getBoundingClientRect();
+ return {
+ x: rect?.left ?? 0,
+ y: rect?.top ?? 0,
+ vx: (Math.random() - 0.5) * 2 * INITIAL_HORIZONTAL_RANGE,
+ vy:
+ INITIAL_POP_MIN +
+ Math.random() * (INITIAL_POP_MAX - INITIAL_POP_MIN),
+ rotation: 0,
+ angularVelocity: (Math.random() - 0.5) * 2 * INITIAL_SPIN,
+ isResting: false,
+ };
+ });
+ // Clear any data-resting markers from the previous run so returned dots
+ // can be re-thrown without their cursor: pointer lingering.
+ flyingRefs.current.forEach((el) => {
+ if (el) el.removeAttribute('data-resting');
+ });
+ setReturningDots([false, false, false]);
+ setReturnedDots([false, false, false]);
+ setIsEscaping(true);
+ };
+ window.addEventListener(TRAFFIC_LIGHTS_ESCAPE_EVENT, handleEscape);
+ return () => {
+ window.removeEventListener(TRAFFIC_LIGHTS_ESCAPE_EVENT, handleEscape);
+ };
+ }, [isEscaping]);
+
+ const handleCatchDot = (index: number) => {
+ setReturningDots((prev) => {
+ if (prev[index]) return prev;
+ const next = [...prev];
+ next[index] = true;
+ return next;
+ });
+ };
+
+ const handlePopAnimationEnd = (index: number) => {
+ setReturnedDots((prev) => {
+ if (prev[index]) return prev;
+ const next = [...prev];
+ next[index] = true;
+ return next;
+ });
+ };
+
+ useEffect(() => {
+ if (!isEscaping) return;
+ let rafId = 0;
+
+ const tick = () => {
+ const floor = window.innerHeight - TRAFFIC_LIGHT_DOT_SIZE - FLOOR_PADDING;
+ const rightWall = window.innerWidth - TRAFFIC_LIGHT_DOT_SIZE;
+ physicsRef.current.forEach((p, i) => {
+ const el = flyingRefs.current[i];
+ if (!el) return;
+ if (p.isResting) {
+ return;
+ }
+
+ p.vy += GRAVITY;
+ p.vx *= AIR_FRICTION;
+ p.x += p.vx;
+ p.y += p.vy;
+ p.rotation += p.angularVelocity;
+
+ if (p.y >= floor) {
+ p.y = floor;
+ p.vx *= GROUND_FRICTION;
+ if (Math.abs(p.vy) < REST_VELOCITY) {
+ p.vy = 0;
+ p.angularVelocity = 0;
+ if (Math.abs(p.vx) < 0.3) {
+ p.vx = 0;
+ if (!p.isResting) {
+ p.isResting = true;
+ el.setAttribute('data-resting', 'true');
+ }
+ }
+ } else {
+ p.vy = -p.vy * BOUNCE_DAMPING;
+ p.angularVelocity *= 0.7;
+ }
+ }
+
+ if (p.x < 0) {
+ p.x = 0;
+ p.vx = -p.vx * BOUNCE_DAMPING;
+ } else if (p.x > rightWall) {
+ p.x = rightWall;
+ p.vx = -p.vx * BOUNCE_DAMPING;
+ }
+
+ el.style.transform = `translate(${p.x}px, ${p.y}px) rotate(${p.rotation}deg)`;
+ });
+ const allSettled = physicsRef.current.every(
+ (p, i) => p.isResting || !flyingRefs.current[i],
+ );
+ if (allSettled) {
+ // Every dot has either come to rest or been caught/returned — suspend
+ // the rAF loop. A scroll impulse or a fresh escape re-flings dots and
+ // the tick is restarted from that effect.
+ rafId = 0;
+ return;
+ }
+ rafId = requestAnimationFrame(tick);
+ };
+ rafId = requestAnimationFrame(tick);
+
+ const handleScroll = () => {
+ let anyDisturbed = false;
+ physicsRef.current.forEach((p, i) => {
+ if (!flyingRefs.current[i]) return;
+ p.isResting = false;
+ flyingRefs.current[i]?.removeAttribute('data-resting');
+ p.vy = -(
+ SCROLL_IMPULSE_MIN +
+ Math.random() * (SCROLL_IMPULSE_MAX - SCROLL_IMPULSE_MIN)
+ );
+ p.vx += (Math.random() - 0.5) * 2 * SCROLL_HORIZONTAL_RANGE;
+ p.angularVelocity += (Math.random() - 0.5) * 8;
+ anyDisturbed = true;
+ });
+ if (anyDisturbed && rafId === 0) {
+ rafId = requestAnimationFrame(tick);
+ }
+ };
+ window.addEventListener('scroll', handleScroll, { passive: true });
+
+ return () => {
+ cancelAnimationFrame(rafId);
+ window.removeEventListener('scroll', handleScroll);
+ };
+ }, [isEscaping]);
+
+ const setOriginalRef = (index: number) => (el: HTMLButtonElement | null) => {
+ originalRefs.current[index] = el;
+ };
+
+ const setFlyingRef = (index: number) => (el: HTMLButtonElement | null) => {
+ flyingRefs.current[index] = el;
+ if (el) {
+ // Seed with the captured starting position so the first paint before
+ // rAF runs doesn't flash the dot in the top-left corner.
+ const p = physicsRef.current[index];
+ if (p) {
+ el.style.transform = `translate(${p.x}px, ${p.y}px)`;
+ }
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {isEscaping && portalReady
+ ? createPortal(
+ <>
+ {DOT_DEFINITIONS.map(
+ ({ background, backgroundActive, Glyph }, index) =>
+ returnedDots[index] ? null : (
+ handleCatchDot(index)}
+ ref={setFlyingRef(index)}
+ type="button"
+ >
+ handlePopAnimationEnd(index)}
+ >
+
+
+
+ ),
+ )}
+ >,
+ document.body,
+ )
+ : null}
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/AssistantResponse.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/AssistantResponse.tsx
new file mode 100644
index 00000000000..e56139dcfb6
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/AssistantResponse.tsx
@@ -0,0 +1,521 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
+import { TERMINAL_TOKENS } from '../terminalTokens';
+import { CHAT_TIMINGS } from './animationTiming';
+import { ChangesSummaryCard } from './ChangesSummaryCard';
+import { StreamingText, type StreamingSegment } from './StreamingText';
+import { ThinkingIndicator } from './ThinkingIndicator';
+
+// Delay between one paragraph finishing its stream and the next starting —
+// gives the reader a beat before new text appears.
+const BETWEEN_PARAGRAPHS_MS = 320;
+// Extra breath after each object paragraph, so the sidebar pop-in has a
+// moment to settle before the next object's paragraph begins streaming.
+const AFTER_OBJECT_BEAT_MS = 520;
+// Delay before the diff card slides in after the prose is done.
+const BEFORE_CARD_MS = 420;
+// Delay after the card lands before we signal the chat is finished.
+const AFTER_CARD_REVEAL_MS = 180;
+
+const ResponseRoot = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ width: 100%;
+`;
+
+const Paragraph = styled.p`
+ color: ${TERMINAL_TOKENS.text.prompt};
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ font-size: 13px;
+ line-height: 20px;
+ margin: 0;
+`;
+
+const InlineCode = styled.span`
+ background: rgba(0, 0, 0, 0.045);
+ border-radius: 3px;
+ color: rgba(0, 0, 0, 0.78);
+ font-family: ${TERMINAL_TOKENS.font.mono};
+ font-size: 12px;
+ padding: 1px 5px;
+`;
+
+const FileLink = styled.span`
+ color: #2a66de;
+ cursor: pointer;
+ font-family: ${TERMINAL_TOKENS.font.mono};
+ font-size: 12px;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
+const ReferenceLink = styled.a`
+ color: #2a66de;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+
+ &:hover {
+ color: #1e4ea8;
+ }
+`;
+
+const CardWrap = styled.div<{ $instant: boolean }>`
+ animation: ${({ $instant }) =>
+ $instant
+ ? 'none'
+ : 'chatCardRise 420ms cubic-bezier(0.22, 1, 0.36, 1) both'};
+
+ @keyframes chatCardRise {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+// -- Paragraph segment builders --
+
+const text = (value: string, onReveal?: () => void): StreamingSegment => ({
+ kind: 'text',
+ value,
+ onReveal,
+});
+const node = (
+ key: string,
+ value: ReactNode,
+ onReveal?: () => void,
+): StreamingSegment => ({
+ kind: 'node',
+ value: {value},
+ onReveal,
+});
+
+// Sidebar ids mirror the ones in rocketObject.ts; keep in sync.
+const ROCKET_ID = 'rockets';
+const LAUNCH_ID = 'launches';
+const PAYLOAD_ID = 'payloads';
+// Customers re-use the standard Companies object — the chat highlights the
+// existing Companies sidebar item instead of creating a new Customer object.
+const COMPANIES_ID = 'companies';
+const LAUNCH_SITE_ID = 'launch-sites';
+
+const buildIntroAndRocketParagraph = (
+ onObjectCreated?: (id: string) => void,
+): StreamingSegment[] => [
+ text(
+ "I'll scaffold a launch-ops CRM in your workspace — four new objects plus the standard ",
+ ),
+ node('rocket-companies', Companies),
+ text(' object for customers, with shared UUIDs in '),
+ node('rocket-ids', schema-identifiers.ts),
+ text('. First up: '),
+ node(
+ 'rocket-chip',
+ Rocket,
+ onObjectCreated ? () => onObjectCreated(ROCKET_ID) : undefined,
+ ),
+ text(
+ ' — each vehicle gets a serial number, manufacturer, lifecycle status, reusability, launch date, dimensions, and target orbit in ',
+ ),
+ node('rocket-file', rocket.object.ts),
+ text('.'),
+];
+
+const buildLaunchParagraph = (
+ onObjectCreated?: (id: string) => void,
+): StreamingSegment[] => [
+ text('Next up: '),
+ node(
+ 'launch-chip',
+ Launch,
+ onObjectCreated ? () => onObjectCreated(LAUNCH_ID) : undefined,
+ ),
+ text(
+ ' — every mission gets a unique mission code, status, mission type, planned and actual launch times, and a summary. Defined in ',
+ ),
+ node('launch-file', launch.object.ts),
+ text('.'),
+];
+
+const buildPayloadParagraph = (
+ onObjectCreated?: (id: string) => void,
+): StreamingSegment[] => [
+ text('Now '),
+ node(
+ 'payload-chip',
+ Payload,
+ onObjectCreated ? () => onObjectCreated(PAYLOAD_ID) : undefined,
+ ),
+ text(
+ ' — what actually flies: satellites, crew capsules, cargo, probes, landers — with type, status, target orbit, mass, and a customer reference. Scoped in ',
+ ),
+ node('payload-file', payload.object.ts),
+ text('.'),
+];
+
+const buildCustomerParagraph = (
+ onObjectCreated?: (id: string) => void,
+): StreamingSegment[] => [
+ text('For customers, no new object — I reuse the standard '),
+ node(
+ 'customer-chip',
+ Companies,
+ onObjectCreated ? () => onObjectCreated(COMPANIES_ID) : undefined,
+ ),
+ text(
+ ' object that ships with Twenty, so accounts, domain favicons, and the People relation work for free. ',
+ ),
+ node('customer-file', payload.object.ts),
+ text(' points its '),
+ node('customer-field', customer),
+ text(' relation straight at it.'),
+];
+
+const buildLaunchSiteParagraph = (
+ onObjectCreated?: (id: string) => void,
+): StreamingSegment[] => [
+ text('Last object: '),
+ node(
+ 'launch-site-chip',
+ Launch site,
+ onObjectCreated ? () => onObjectCreated(LAUNCH_SITE_ID) : undefined,
+ ),
+ text(
+ ' — pads and ranges with a site code, country, region, pad name, and operational status. Lives in ',
+ ),
+ node('launch-site-file', launch-site.object.ts),
+ text('.'),
+];
+
+const PINNED_ACTIONS_PARAGRAPH: StreamingSegment[] = [
+ text(
+ 'Each object also gets 2-3 relevant quick commands pinned to its header, to the left of ',
+ ),
+ node('pa-new', New),
+ text(' — '),
+ node('pa-rocket', Rocket),
+ text(' has reuse / retire shortcuts, '),
+ node('pa-launch', Launch),
+ text(' has '),
+ node('pa-l-resched', Reschedule),
+ text(' and '),
+ node('pa-l-payload', Add payload),
+ text(', '),
+ node('pa-payload', Payload),
+ text(' has '),
+ node('pa-p-book', Book slot),
+ text(', '),
+ node('pa-companies', Companies),
+ text(' has a quick '),
+ node('pa-c-status', Set status),
+ text(', and '),
+ node('pa-site', Launch site),
+ text(' has '),
+ node('pa-s-window', Book window),
+ text('. Defined under '),
+ node('pa-folder', src/command-menu-items/),
+ text('.'),
+];
+
+const WRAPUP_PARAGRAPH: StreamingSegment[] = [
+ text('Relations wire '),
+ node('w-rl', Rocket → Launches),
+ text(', '),
+ node('w-sl', LaunchSite → Launches),
+ text(', '),
+ node('w-cp', Company → Payloads),
+ text(', and '),
+ node('w-lp', Launch → Payloads),
+ text('. Each object gets an index view and sidebar entry; '),
+ node('w-launches', Launches),
+ text(' also has '),
+ node('w-upcoming', upcoming-launches.view.ts),
+ text(' and '),
+ node('w-past', past-launches.view.ts),
+ text('. Verified with '),
+ node('w-lint', yarn lint),
+ text(', '),
+ node('w-tsc', tsc --noEmit),
+ text(', '),
+ node(
+ 'w-vitest',
+ vitest run schema.integration-test.ts,
+ ),
+ text(', and '),
+ node('w-dev', yarn twenty dev --once),
+ text('. Reference: '),
+ node(
+ 'w-docs',
+ event.preventDefault()}
+ >
+ Twenty app-building docs
+ ,
+ ),
+ text('.'),
+];
+
+type Stage =
+ | 'thinking'
+ | 'rocket'
+ | 'launch'
+ | 'payload'
+ | 'customer'
+ | 'launchSite'
+ | 'actions'
+ | 'wrapup'
+ | 'card'
+ | 'done';
+
+const STAGE_ORDER: Stage[] = [
+ 'thinking',
+ 'rocket',
+ 'launch',
+ 'payload',
+ 'customer',
+ 'launchSite',
+ 'actions',
+ 'wrapup',
+ 'card',
+ 'done',
+];
+
+// Progressive renderer: advances to the next stage once the previous one
+// signals completion (via StreamingText's `onComplete`). Every transition
+// flows through setTimeout so it's trivial to retime via the constants at the
+// top of the file. Each object sentence is its own stage so the sidebar pop-in
+// has room to breathe before the next object is mentioned.
+type AssistantResponseProps = {
+ instantComplete?: boolean;
+ onUndo?: () => void;
+ onObjectCreated?: (id: string) => void;
+ onChatFinished?: () => void;
+};
+
+export const AssistantResponse = ({
+ instantComplete = false,
+ onUndo,
+ onObjectCreated,
+ onChatFinished,
+}: AssistantResponseProps) => {
+ const [stage, setStage] = useState(
+ instantComplete ? 'done' : 'thinking',
+ );
+ const hasNotifiedChatFinishedRef = useRef(false);
+ const advanceTimeoutsRef = useRef>(new Set());
+ const objectCreationHandler = instantComplete ? undefined : onObjectCreated;
+
+ useEffect(() => {
+ const timeouts = advanceTimeoutsRef.current;
+ return () => {
+ timeouts.forEach((id) => window.clearTimeout(id));
+ timeouts.clear();
+ };
+ }, []);
+
+ // Segments are rebuilt whenever the caller's onObjectCreated identity
+ // changes so each object chip's onReveal is wired to the latest handler.
+ // HomeVisual memoizes the handler with useCallback, so segments stay stable
+ // during streaming and StreamingText doesn't reset mid-reveal.
+ const rocketParagraph = useMemo(
+ () => buildIntroAndRocketParagraph(objectCreationHandler),
+ [objectCreationHandler],
+ );
+ const launchParagraph = useMemo(
+ () => buildLaunchParagraph(objectCreationHandler),
+ [objectCreationHandler],
+ );
+ const payloadParagraph = useMemo(
+ () => buildPayloadParagraph(objectCreationHandler),
+ [objectCreationHandler],
+ );
+ const customerParagraph = useMemo(
+ () => buildCustomerParagraph(objectCreationHandler),
+ [objectCreationHandler],
+ );
+ const launchSiteParagraph = useMemo(
+ () => buildLaunchSiteParagraph(objectCreationHandler),
+ [objectCreationHandler],
+ );
+
+ useEffect(() => {
+ if (instantComplete) {
+ return undefined;
+ }
+ const id = window.setTimeout(
+ () => setStage('rocket'),
+ CHAT_TIMINGS.thinkingMs,
+ );
+ return () => window.clearTimeout(id);
+ }, [instantComplete]);
+
+ useEffect(() => {
+ if (!instantComplete) {
+ return;
+ }
+ setStage('done');
+ }, [instantComplete]);
+
+ useEffect(() => {
+ if (
+ (stage !== 'card' && stage !== 'done') ||
+ hasNotifiedChatFinishedRef.current
+ ) {
+ return undefined;
+ }
+ hasNotifiedChatFinishedRef.current = true;
+ const id = window.setTimeout(
+ () => {
+ onChatFinished?.();
+ },
+ stage === 'done' ? 0 : AFTER_CARD_REVEAL_MS,
+ );
+ return () => window.clearTimeout(id);
+ }, [stage, onChatFinished]);
+
+ const advanceTo = useCallback(
+ (next: Stage, delayMs: number) => () => {
+ const id = window.setTimeout(() => {
+ advanceTimeoutsRef.current.delete(id);
+ setStage(next);
+ }, delayMs);
+ advanceTimeoutsRef.current.add(id);
+ },
+ [],
+ );
+
+ const has = (target: Stage): boolean =>
+ STAGE_ORDER.indexOf(stage) >= STAGE_ORDER.indexOf(target);
+
+ return (
+
+ {stage === 'thinking' && }
+
+ {has('rocket') && (
+
+
+
+ )}
+
+ {has('launch') && (
+
+
+
+ )}
+
+ {has('payload') && (
+
+
+
+ )}
+
+ {has('customer') && (
+
+
+
+ )}
+
+ {has('launchSite') && (
+
+
+
+ )}
+
+ {has('actions') && (
+
+
+
+ )}
+
+ {has('wrapup') && (
+
+
+
+ )}
+
+ {has('card') && (
+
+
+
+ )}
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ChangesSummaryCard.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ChangesSummaryCard.tsx
new file mode 100644
index 00000000000..54949b2c510
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ChangesSummaryCard.tsx
@@ -0,0 +1,269 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import {
+ IconArrowBackUp,
+ IconChevronDown,
+ IconChevronRight,
+ IconChevronUp,
+} from '@tabler/icons-react';
+import { useState } from 'react';
+import { CHAT_TIMINGS } from './animationTiming';
+import {
+ CHANGESET_TOTALS,
+ ROCKET_CHANGESET,
+ type FileChange,
+} from './rocketChangeset';
+
+const ROW_STAGGER_MS = 24;
+const ROW_BASE_DELAY_MS = 40;
+const COLLAPSED_FILE_COUNT = 3;
+
+const CardRoot = styled.div`
+ animation: chatCardRise ${CHAT_TIMINGS.fileCardEnterMs}ms
+ cubic-bezier(0.22, 1, 0.36, 1) both;
+ background: #f5f5f5;
+ border: 1px solid rgba(0, 0, 0, 0.08);
+ border-radius: 10px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
+ overflow: hidden;
+ width: 100%;
+
+ @keyframes chatCardRise {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+const Header = styled.div`
+ align-items: center;
+ background: #fafafa;
+ display: flex;
+ gap: 8px;
+ padding: 10px 14px;
+`;
+
+const HeaderTitle = styled.div`
+ align-items: center;
+ color: rgba(0, 0, 0, 0.72);
+ display: flex;
+ flex: 1 1 auto;
+ font-family: 'Inter', sans-serif;
+ font-size: 12.5px;
+ font-weight: 500;
+ gap: 6px;
+`;
+
+const DiffAdded = styled.span`
+ color: #2f7d52;
+ font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
+ font-weight: 600;
+`;
+
+const DiffRemoved = styled.span`
+ color: #a94a4f;
+ font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
+ font-weight: 600;
+`;
+
+const UndoButton = styled.button`
+ align-items: center;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: rgba(0, 0, 0, 0.55);
+ cursor: pointer;
+ display: inline-flex;
+ font-family: 'Inter', sans-serif;
+ font-size: 12px;
+ font-weight: 500;
+ gap: 3px;
+ padding: 3px 6px;
+ transition:
+ background-color 0.14s ease,
+ color 0.14s ease;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.04);
+ color: rgba(0, 0, 0, 0.8);
+ }
+`;
+
+const FileList = styled.div`
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
+ display: flex;
+ flex-direction: column;
+`;
+
+const FileRow = styled.div<{ $delay: string }>`
+ animation: chatFileRowFade 240ms ease-out both;
+ animation-delay: ${({ $delay }) => $delay};
+ align-items: center;
+ display: flex;
+ gap: 10px;
+ padding: 9px 14px;
+ transition: background-color 0.14s ease;
+
+ & + & {
+ border-top: 1px solid rgba(0, 0, 0, 0.04);
+ }
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.02);
+ }
+
+ @keyframes chatFileRowFade {
+ from {
+ opacity: 0;
+ transform: translateY(3px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+const FilePath = styled.span`
+ color: rgba(0, 0, 0, 0.78);
+ flex: 1 1 auto;
+ font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
+ font-size: 11.5px;
+ font-weight: 500;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const DiffCounts = styled.span`
+ align-items: center;
+ display: inline-flex;
+ flex: 0 0 auto;
+ font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
+ font-size: 11px;
+ gap: 6px;
+`;
+
+const Chevron = styled.span`
+ align-items: center;
+ color: rgba(0, 0, 0, 0.3);
+ display: inline-flex;
+ flex: 0 0 auto;
+`;
+
+const ZeroCount = styled.span`
+ color: rgba(0, 0, 0, 0.35);
+`;
+
+const renderDiffCounts = (change: FileChange) => (
+
+ {change.added > 0 ? (
+ +{change.added}
+ ) : (
+ +0
+ )}
+ {change.removed > 0 ? (
+ -{change.removed}
+ ) : (
+ -0
+ )}
+
+);
+
+const SeeMoreButton = styled.button`
+ align-items: center;
+ background: transparent;
+ border: none;
+ border-top: 1px solid rgba(0, 0, 0, 0.05);
+ color: rgba(0, 0, 0, 0.48);
+ cursor: pointer;
+ display: inline-flex;
+ font-family: 'Inter', sans-serif;
+ font-size: 11.5px;
+ font-weight: 500;
+ gap: 4px;
+ justify-content: flex-start;
+ letter-spacing: 0.1px;
+ padding: 8px 14px;
+ transition: color 0.14s ease;
+ width: 100%;
+
+ &:hover {
+ color: rgba(0, 0, 0, 0.72);
+ }
+`;
+
+const SeeMoreChevron = styled.span`
+ align-items: center;
+ color: currentColor;
+ display: inline-flex;
+ flex: 0 0 auto;
+`;
+
+type ChangesSummaryCardProps = {
+ onUndo?: () => void;
+};
+
+export const ChangesSummaryCard = ({ onUndo }: ChangesSummaryCardProps) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const hiddenCount = Math.max(
+ ROCKET_CHANGESET.length - COLLAPSED_FILE_COUNT,
+ 0,
+ );
+ const visibleChanges =
+ isExpanded || hiddenCount === 0
+ ? ROCKET_CHANGESET
+ : ROCKET_CHANGESET.slice(0, COLLAPSED_FILE_COUNT);
+
+ return (
+
+
+
+ {ROCKET_CHANGESET.length} files changed
+ +{CHANGESET_TOTALS.added}
+ -{CHANGESET_TOTALS.removed}
+
+
+ Undo
+
+
+
+
+ {visibleChanges.map((change, index) => (
+
+ {change.path}
+ {renderDiffCounts(change)}
+
+
+
+
+ ))}
+
+ {hiddenCount > 0 && (
+ setIsExpanded((current) => !current)}
+ type="button"
+ >
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ {isExpanded ? 'See less' : `See ${hiddenCount} more`}
+
+ )}
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ConversationPanel.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ConversationPanel.tsx
new file mode 100644
index 00000000000..946d43f2c8c
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ConversationPanel.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import { useEffect, useRef } from 'react';
+import { AssistantResponse } from './AssistantResponse';
+import { UserMessage } from './UserMessage';
+
+export type ConversationMessage =
+ | { id: string; role: 'user'; text: string }
+ | { id: string; role: 'assistant' };
+
+type ConversationPanelProps = {
+ instantComplete?: boolean;
+ messages: ConversationMessage[];
+ onUndo?: () => void;
+ onObjectCreated?: (id: string) => void;
+ onChatFinished?: () => void;
+};
+
+const PanelRoot = styled.div`
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 18px;
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding: 12px 12px 4px;
+ scroll-behavior: smooth;
+ scrollbar-gutter: stable both-edges;
+ width: 100%;
+
+ &::-webkit-scrollbar {
+ width: 8px;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: rgba(0, 0, 0, 0.1);
+ border-radius: 999px;
+ }
+`;
+
+export const ConversationPanel = ({
+ instantComplete = false,
+ messages,
+ onUndo,
+ onObjectCreated,
+ onChatFinished,
+}: ConversationPanelProps) => {
+ const scrollRef = useRef(null);
+
+ // Keep the view pinned to the bottom as content grows — whether that's a
+ // new message landing or the assistant streaming the next character. We
+ // watch the subtree for any DOM / text change and coalesce scrolls into a
+ // single request per animation frame so streaming stays smooth.
+ useEffect(() => {
+ const element = scrollRef.current;
+ if (!element) {
+ return undefined;
+ }
+
+ let frameId: number | null = null;
+ const scheduleScroll = () => {
+ if (frameId !== null) {
+ return;
+ }
+ frameId = window.requestAnimationFrame(() => {
+ frameId = null;
+ element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' });
+ });
+ };
+
+ scheduleScroll();
+
+ const observer = new MutationObserver(scheduleScroll);
+ observer.observe(element, {
+ characterData: true,
+ childList: true,
+ subtree: true,
+ });
+
+ return () => {
+ observer.disconnect();
+ if (frameId !== null) {
+ window.cancelAnimationFrame(frameId);
+ }
+ };
+ }, []);
+
+ return (
+
+ {messages.map((message) =>
+ message.role === 'user' ? (
+
+ ) : (
+
+ ),
+ )}
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/StreamingText.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/StreamingText.tsx
new file mode 100644
index 00000000000..dc1229cb8e3
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/StreamingText.tsx
@@ -0,0 +1,156 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import { useEffect, useRef, useState, type ReactNode } from 'react';
+
+type StreamingSegment =
+ | { kind: 'text'; value: string; onReveal?: () => void }
+ | {
+ kind: 'node';
+ value: ReactNode;
+ length?: number;
+ onReveal?: () => void;
+ };
+
+type StreamingTextProps = {
+ segments: ReadonlyArray;
+ charDurationMs?: number;
+ instant?: boolean;
+ onComplete?: () => void;
+};
+
+const StreamWrap = styled.span`
+ display: inline;
+`;
+
+const Caret = styled.span`
+ animation: chatCaretBlink 1s steps(1, end) infinite;
+ background: currentColor;
+ display: inline-block;
+ height: 1em;
+ margin-left: 2px;
+ opacity: 0.55;
+ vertical-align: text-bottom;
+ width: 1.5px;
+
+ @keyframes chatCaretBlink {
+ 0%,
+ 50% {
+ opacity: 0.55;
+ }
+ 51%,
+ 100% {
+ opacity: 0;
+ }
+ }
+`;
+
+// Reveals a mixed sequence of plain text + JSX nodes character-by-character.
+// Each node occupies `length` characters in the animation (default 1) so the
+// pacing stays uniform even when inline pills / links are sprinkled in.
+export const StreamingText = ({
+ segments,
+ charDurationMs = 14,
+ instant = false,
+ onComplete,
+}: StreamingTextProps) => {
+ const totalLength = segments.reduce(
+ (acc, segment) =>
+ acc +
+ (segment.kind === 'text' ? segment.value.length : (segment.length ?? 1)),
+ 0,
+ );
+
+ const [revealed, setRevealed] = useState(0);
+ const onCompleteRef = useRef(onComplete);
+ const completedRef = useRef(false);
+ const firedSegmentCountRef = useRef(0);
+
+ onCompleteRef.current = onComplete;
+
+ useEffect(() => {
+ setRevealed(0);
+ completedRef.current = false;
+ firedSegmentCountRef.current = 0;
+ }, [segments]);
+
+ useEffect(() => {
+ if (!instant) {
+ return;
+ }
+ setRevealed(totalLength);
+ }, [instant, totalLength]);
+
+ useEffect(() => {
+ if (revealed >= totalLength) {
+ if (!completedRef.current) {
+ completedRef.current = true;
+ onCompleteRef.current?.();
+ }
+ return undefined;
+ }
+ if (instant) {
+ return undefined;
+ }
+ const id = window.setTimeout(() => {
+ setRevealed((previous) => Math.min(previous + 1, totalLength));
+ }, charDurationMs);
+ return () => window.clearTimeout(id);
+ }, [charDurationMs, instant, revealed, totalLength]);
+
+ // Fire per-segment `onReveal` callbacks exactly once as each segment becomes
+ // fully revealed by the streamer. Walking segments in order on every
+ // `revealed` tick is cheap (paragraphs are short) and keeps firing order
+ // deterministic.
+ useEffect(() => {
+ let offset = 0;
+ for (let index = 0; index < segments.length; index += 1) {
+ const segment = segments[index];
+ const cost =
+ segment.kind === 'text' ? segment.value.length : (segment.length ?? 1);
+ offset += cost;
+ if (revealed < offset) {
+ break;
+ }
+ if (index < firedSegmentCountRef.current) {
+ continue;
+ }
+ segment.onReveal?.();
+ firedSegmentCountRef.current = index + 1;
+ }
+ }, [revealed, segments]);
+
+ const rendered: ReactNode[] = [];
+ let remaining = revealed;
+ for (let index = 0; index < segments.length; index += 1) {
+ const segment = segments[index];
+ if (remaining <= 0) {
+ break;
+ }
+ if (segment.kind === 'text') {
+ const take = Math.min(segment.value.length, remaining);
+ rendered.push(
+ {segment.value.slice(0, take)},
+ );
+ remaining -= take;
+ } else {
+ const cost = segment.length ?? 1;
+ if (remaining < cost) {
+ break;
+ }
+ rendered.push({segment.value});
+ remaining -= cost;
+ }
+ }
+
+ const isComplete = revealed >= totalLength;
+
+ return (
+
+ {rendered}
+ {!isComplete && }
+
+ );
+};
+
+export type { StreamingSegment };
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ThinkingIndicator.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ThinkingIndicator.tsx
new file mode 100644
index 00000000000..af9c158f0d0
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/ThinkingIndicator.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { styled } from '@linaria/react';
+
+const ThinkingRoot = styled.div`
+ align-items: center;
+ display: inline-flex;
+ gap: 4px;
+ padding: 4px 0;
+`;
+
+const ThinkingDot = styled.span<{ $delay: string }>`
+ animation: chatThinkingBounce 1.2s ease-in-out infinite;
+ animation-delay: ${({ $delay }) => $delay};
+ background: rgba(0, 0, 0, 0.45);
+ border-radius: 999px;
+ display: inline-block;
+ height: 5px;
+ width: 5px;
+
+ @keyframes chatThinkingBounce {
+ 0%,
+ 65%,
+ 100% {
+ opacity: 0.3;
+ transform: translateY(0);
+ }
+ 30% {
+ opacity: 1;
+ transform: translateY(-3px);
+ }
+ }
+`;
+
+export const ThinkingIndicator = () => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/UserMessage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/UserMessage.tsx
new file mode 100644
index 00000000000..ec007865294
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/UserMessage.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import { TERMINAL_TOKENS } from '../terminalTokens';
+import { CHAT_TIMINGS } from './animationTiming';
+
+type UserMessageProps = {
+ instant?: boolean;
+ text: string;
+};
+
+const BubbleRow = styled.div<{ $instant: boolean }>`
+ animation: ${({ $instant }) =>
+ $instant
+ ? 'none'
+ : `chatBubbleRise ${CHAT_TIMINGS.bubbleEnterMs}ms cubic-bezier(0.22, 1, 0.36, 1) both`};
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+
+ @keyframes chatBubbleRise {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+const Bubble = styled.div`
+ background: rgba(0, 0, 0, 0.055);
+ border-radius: 14px;
+ color: ${TERMINAL_TOKENS.text.prompt};
+ font-family: ${TERMINAL_TOKENS.font.ui};
+ font-size: 13px;
+ line-height: 18px;
+ max-width: 80%;
+ padding: 8px 12px;
+`;
+
+export const UserMessage = ({ instant = false, text }: UserMessageProps) => {
+ return (
+
+ {text}
+
+ );
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/animationTiming.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/animationTiming.ts
new file mode 100644
index 00000000000..6d8f25c19fb
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/animationTiming.ts
@@ -0,0 +1,18 @@
+// All chat animation timings live here so the whole sequence can be retuned
+// from a single file. Values are in milliseconds.
+export const CHAT_TIMINGS = {
+ // Beat between the user bubble landing and the assistant starting to think.
+ userToAssistantMs: 320,
+ // How long the thinking dots bounce before the intro text begins streaming.
+ thinkingMs: 700,
+ // How long each character takes during text streaming.
+ textStreamCharMs: 14,
+ // Pause between the intro finishing and the file card appearing.
+ textToFileDelayMs: 380,
+ // How long the file card stays "in-progress" before flipping to done.
+ fileWorkingDelayMs: 900,
+ // Entrance animation duration for user / assistant bubbles.
+ bubbleEnterMs: 280,
+ // Entrance animation duration for the file card.
+ fileCardEnterMs: 320,
+} as const;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/rocketChangeset.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/rocketChangeset.ts
new file mode 100644
index 00000000000..cffed62973c
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/conversation/rocketChangeset.ts
@@ -0,0 +1,123 @@
+// Static diff summary displayed after the assistant scaffolds the launch-ops
+// CRM (Rocket, Launch, Payload, Customer, Launch site). Matches the shape of
+// an IDE / Claude Code changeset.
+
+export type FileChange = {
+ path: string;
+ added: number;
+ removed: number;
+};
+
+export const ROCKET_CHANGESET: ReadonlyArray = [
+ { path: 'src/__tests__/schema.integration-test.ts', added: 412, removed: 40 },
+ {
+ path: 'src/command-menu-items/add-payload.command-menu-item.ts',
+ added: 20,
+ removed: 0,
+ },
+ {
+ path: 'src/command-menu-items/book-slot.command-menu-item.ts',
+ added: 22,
+ removed: 0,
+ },
+ {
+ path: 'src/command-menu-items/book-window.command-menu-item.ts',
+ added: 22,
+ removed: 0,
+ },
+ {
+ path: 'src/command-menu-items/fly-again.command-menu-item.ts',
+ added: 22,
+ removed: 0,
+ },
+ {
+ path: 'src/command-menu-items/launches-from-site.command-menu-item.ts',
+ added: 19,
+ removed: 0,
+ },
+ {
+ path: 'src/command-menu-items/reschedule-launch.command-menu-item.ts',
+ added: 18,
+ removed: 0,
+ },
+ {
+ path: 'src/command-menu-items/retire-rocket.command-menu-item.ts',
+ added: 20,
+ removed: 0,
+ },
+ {
+ path: 'src/command-menu-items/schedule-launch.command-menu-item.ts',
+ added: 20,
+ removed: 0,
+ },
+ {
+ path: 'src/command-menu-items/set-customer-status.command-menu-item.ts',
+ added: 19,
+ removed: 0,
+ },
+ {
+ path: 'src/command-menu-items/set-payload-status.command-menu-item.ts',
+ added: 18,
+ removed: 0,
+ },
+ {
+ path: 'src/command-menu-items/set-site-status.command-menu-item.ts',
+ added: 19,
+ removed: 0,
+ },
+ {
+ path: 'src/command-menu-items/upcoming-launches.command-menu-item.ts',
+ added: 18,
+ removed: 0,
+ },
+ { path: 'src/constants/schema-identifiers.ts', added: 112, removed: 0 },
+ {
+ path: 'src/navigation-menu-items/launch-sites.navigation-menu-item.ts',
+ added: 16,
+ removed: 0,
+ },
+ {
+ path: 'src/navigation-menu-items/launches.navigation-menu-item.ts',
+ added: 16,
+ removed: 0,
+ },
+ {
+ path: 'src/navigation-menu-items/past-launches.navigation-menu-item.ts',
+ added: 16,
+ removed: 0,
+ },
+ {
+ path: 'src/navigation-menu-items/payloads.navigation-menu-item.ts',
+ added: 16,
+ removed: 0,
+ },
+ {
+ path: 'src/navigation-menu-items/rockets.navigation-menu-item.ts',
+ added: 3,
+ removed: 6,
+ },
+ {
+ path: 'src/navigation-menu-items/upcoming-launches.navigation-menu-item.ts',
+ added: 16,
+ removed: 0,
+ },
+ { path: 'src/objects/launch-site.object.ts', added: 128, removed: 0 },
+ { path: 'src/objects/launch.object.ts', added: 237, removed: 0 },
+ { path: 'src/objects/payload.object.ts', added: 198, removed: 0 },
+ { path: 'src/objects/rocket.object.ts', added: 28, removed: 32 },
+ { path: 'src/page-layouts/rocket-record-page-layout.ts', added: 2, removed: 2 },
+ { path: 'src/views/launch-sites.view.ts', added: 49, removed: 0 },
+ { path: 'src/views/launches.view.ts', added: 70, removed: 0 },
+ { path: 'src/views/past-launches.view.ts', added: 82, removed: 0 },
+ { path: 'src/views/payloads.view.ts', added: 63, removed: 0 },
+ { path: 'src/views/rockets.view.ts', added: 12, removed: 32 },
+ { path: 'src/views/upcoming-launches.view.ts', added: 82, removed: 0 },
+];
+
+export const CHANGESET_TOTALS = ROCKET_CHANGESET.reduce(
+ (acc, file) => ({
+ added: acc.added + file.added,
+ removed: acc.removed + file.removed,
+ }),
+ { added: 0, removed: 0 },
+);
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminalTokens.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminalTokens.ts
new file mode 100644
index 00000000000..7c980dc41c8
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminalTokens.ts
@@ -0,0 +1,51 @@
+// Design tokens extracted from the Figma terminal overlay.
+// Values are pinned to the Figma source of truth so the overlay stays pixel perfect.
+export const TERMINAL_TOKENS = {
+ surface: {
+ window: '#fefefd',
+ windowBorder: 'rgba(0, 0, 0, 0.04)',
+ topBarBorder: 'rgba(0, 0, 0, 0.06)',
+ promptBoxBackground: 'rgba(0, 0, 0, 0.02)',
+ promptBoxBackgroundHover: 'rgba(0, 0, 0, 0.03)',
+ promptBoxBorder: 'rgba(0, 0, 0, 0.08)',
+ promptBoxBorderFocus: 'rgba(0, 0, 0, 0.18)',
+ toggleBackground: 'rgba(9, 9, 11, 0.04)',
+ toggleBorder: 'rgba(9, 9, 11, 0.06)',
+ activeSegmentBackground: '#ffffff',
+ activeSegmentBorder: 'rgba(9, 9, 11, 0.06)',
+ inactiveSegmentHoverBackground: 'rgba(9, 9, 11, 0.04)',
+ chipBackground: '#eef4f1',
+ chipBorder: '#d3dfd9',
+ chipHoverBackground: '#e3ede7',
+ mythosHoverBackground: 'rgba(0, 0, 0, 0.04)',
+ },
+ text: {
+ primary: 'rgba(9, 9, 11, 0.92)',
+ secondary: 'rgba(9, 9, 11, 0.55)',
+ prompt: 'rgba(0, 0, 0, 0.8)',
+ muted: 'rgba(0, 0, 0, 0.56)',
+ mutedHover: 'rgba(0, 0, 0, 0.78)',
+ chip: '#2f7468',
+ },
+ trafficLight: {
+ close: '#FF5F57',
+ closeActive: '#E0443E',
+ minimize: '#FEBC2E',
+ minimizeActive: '#DEA123',
+ zoom: '#28C840',
+ zoomActive: '#1AAB29',
+ glyph: 'rgba(0, 0, 0, 0.55)',
+ },
+ accent: {
+ brand: '#1961ed',
+ brandHover: '#1550c5',
+ },
+ shadow: {
+ activeSegment:
+ '0 0 1px 0 rgba(0, 0, 0, 0.04), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
+ },
+ font: {
+ ui: "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
+ mono: "'Geist Mono', 'SF Mono', ui-monospace, Menlo, Monaco, Consolas, monospace",
+ },
+} as const;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisual.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisual.tsx
index 05a561be591..0b8329c9629 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisual.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisual.tsx
@@ -5,17 +5,22 @@ import { getSharedCompanyLogoUrlFromDomainName } from '@/lib/shared-asset-paths'
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
+ IconBarcode,
IconBook,
+ IconBox,
IconBrandLinkedin,
IconBuildingFactory2,
IconBuildingSkyscraper,
+ IconCalendarClock,
IconCalendarEvent,
+ IconCalendarPlus,
IconCheck,
IconCheckbox,
IconChevronDown,
IconCopy,
IconCreativeCommonsSa,
IconDotsVertical,
+ IconFlag,
IconFolder,
IconHome2,
IconLayoutDashboard,
@@ -24,6 +29,7 @@ import {
IconLink,
IconList,
IconMap2,
+ IconMapPin,
IconMessageCircle,
IconMessageCirclePlus,
IconMoneybag,
@@ -31,10 +37,15 @@ import {
IconPencil,
IconChevronUp,
IconHeart,
+ IconPlanet,
IconPlayerPause,
IconPlayerPlay,
IconPlus,
+ IconProgress,
+ IconRefresh,
IconRepeat,
+ IconRocket,
+ IconRuler,
IconSearch,
IconSettings,
IconSettingsAutomation,
@@ -44,14 +55,16 @@ import {
IconUserCircle,
IconUsers,
IconVersions,
+ IconWeight,
IconX,
} from '@tabler/icons-react';
import {
+ useCallback,
+ useEffect,
useMemo,
- useRef,
useState,
+ type CSSProperties,
type ReactNode,
- type PointerEvent as ReactPointerEvent,
} from 'react';
import type {
HeroDashboardPageDefinition,
@@ -78,12 +91,28 @@ import { normalizeHeroPage, type HeroPageDefaults } from './normalizeHeroPage';
import { KanbanPage } from './KanbanPage';
import { PagePreviewLoader } from './PagePreviewLoader';
import { TablePage } from './TablePage';
+import { DraggableAppWindow } from './DraggableAppWindow/DraggableAppWindow';
+import { DraggableTerminal } from './DraggableTerminal/DraggableTerminal';
+import { OBJECT_PINNED_ACTIONS } from './objectPinnedActions';
+import {
+ COMPANIES_ITEM_ID,
+ COMPANIES_ITEM_LABEL,
+ CRM_OBJECT_SEQUENCE,
+} from './rocketObject';
+import { WindowOrderProvider } from './WindowOrder/WindowOrderProvider';
const APP_FONT = VISUAL_TOKENS.font.family;
const DEFAULT_TABLE_WIDTH = 1700;
const APPLE_WORKSPACE_LOGO_SRC = '/images/home/hero/apple-rainbow-logo.svg';
const TABLE_CELL_HORIZONTAL_PADDING = 8;
const HOVER_ACTION_EDGE_INSET = 4;
+const COMPLETED_CREATED_OBJECT_IDS = CRM_OBJECT_SEQUENCE.map(({ id }) => id);
+const COMPLETED_REVEALED_OBJECT_IDS = [
+ ...COMPLETED_CREATED_OBJECT_IDS,
+ COMPANIES_ITEM_ID,
+];
+const COMPLETED_ACTIVE_OBJECT_LABEL =
+ CRM_OBJECT_SEQUENCE.at(-1)?.label ?? COMPANIES_ITEM_LABEL;
const COLORS = {
accent: VISUAL_TOKENS.accent.accent9,
@@ -118,6 +147,29 @@ const SIDEBAR_TONES: Record<
red: { background: '#fdd8d8', border: '#f9c6c6', color: '#DC3D43' },
};
+// RGB tuples for each tone's accent color. Used by the object-appearance
+// animation to tint the highlight/ring to match the newly-created object's
+// sidebar icon (e.g. Rocket = violet, Launch = orange, Payload = teal).
+const hexToRgbTuple = (hex: string): string => {
+ const clean = hex.replace('#', '');
+ const expanded =
+ clean.length === 3
+ ? clean
+ .split('')
+ .map((char) => char + char)
+ .join('')
+ : clean;
+ const value = parseInt(expanded, 16);
+ return `${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}`;
+};
+
+const SIDEBAR_TONE_RGB: Record = Object.fromEntries(
+ Object.entries(SIDEBAR_TONES).map(([tone, palette]) => [
+ tone,
+ hexToRgbTuple(palette.color),
+ ]),
+);
+
const PERSON_TONES: Record = {
amber: { background: '#f6e6d7', color: '#7a4f2a' },
blue: { background: '#dbeafe', color: '#1d4ed8' },
@@ -131,19 +183,26 @@ const PERSON_TONES: Record = {
const TABLER_STROKE = 1.6;
const NAVIGATION_TABLER_STROKE = 2;
+const NAVBAR_ACTION_TABLER_STROKE = 2;
const ROW_HOVER_ACTION_DISABLED_COLUMNS = new Set([
'createdBy',
'accountOwner',
]);
const NAVBAR_ACTION_ICON_MAP: Record = {
+ box: IconBox,
+ calendarClock: IconCalendarClock,
+ calendarEvent: IconCalendarEvent,
+ calendarPlus: IconCalendarPlus,
chevronDown: IconChevronDown,
chevronUp: IconChevronUp,
dotsVertical: IconDotsVertical,
+ flag: IconFlag,
heart: IconHeart,
playerPause: IconPlayerPause,
plus: IconPlus,
repeat: IconRepeat,
+ rocket: IconRocket,
};
const SalesDashboardPage = dynamic(
@@ -202,45 +261,43 @@ const StyledHomeVisual = styled.div`
`;
const ShellScene = styled.div`
+ aspect-ratio: 1 / 1;
margin: 0 auto;
- transform-origin: center top;
- transition: transform 0.18s ease;
- width: 100%;
-`;
-
-const Frame = styled.div`
- aspect-ratio: 1280 / 832;
- background-color: ${COLORS.background};
- background-image: ${VISUAL_TOKENS.background.noisy};
- border: 1px solid ${COLORS.border};
- border-radius: 8px;
- box-shadow: ${COLORS.shadow};
max-height: 740px;
- overflow: hidden;
position: relative;
width: 100%;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ aspect-ratio: 1280 / 832;
+ }
`;
const AppLayout = styled.div`
display: flex;
+ flex: 1 1 auto;
height: 100%;
+ min-width: 0;
+ overflow: hidden;
min-height: 0;
position: relative;
+ width: 100%;
z-index: 1;
`;
const SidebarPanel = styled.aside`
background: transparent;
display: grid;
- flex: 0 0 72px;
- gap: 12px;
+ flex: 0 0 48px;
+ gap: 8px;
grid-template-rows: auto auto minmax(0, 1fr);
min-height: 0;
- padding: 12px 8px;
- width: 72px;
+ padding: 8px 4px;
+ width: 48px;
@media (min-width: ${theme.breakpoints.md}px) {
flex-basis: 220px;
+ gap: 12px;
+ padding: 12px 8px;
width: 220px;
}
`;
@@ -248,18 +305,39 @@ const SidebarPanel = styled.aside`
const SidebarTopBar = styled.div`
align-items: center;
display: grid;
- gap: 8px;
- grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-columns: minmax(0, 1fr);
min-height: 32px;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ gap: 8px;
+ grid-template-columns: minmax(0, 1fr) auto;
+ }
`;
const WorkspaceMenu = styled.div`
align-items: center;
display: grid;
- gap: 8px;
- grid-template-columns: auto 1fr auto;
+ gap: 4px;
+ grid-auto-flow: column;
+ grid-template-columns: auto;
+ justify-content: center;
min-width: 0;
padding: 6px 4px;
+
+ > svg:last-child {
+ display: none;
+ }
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ gap: 8px;
+ grid-auto-flow: row;
+ grid-template-columns: auto 1fr auto;
+ justify-content: stretch;
+
+ > svg:last-child {
+ display: block;
+ }
+ }
`;
const WorkspaceIcon = styled.div`
@@ -268,12 +346,14 @@ const WorkspaceIcon = styled.div`
flex: 0 0 auto;
height: 16px;
justify-content: center;
- width: 14px;
+ width: 16px;
`;
const WorkspaceIconImage = styled.img`
display: block;
height: 100%;
+ object-fit: contain;
+ object-position: center;
width: 100%;
`;
@@ -296,9 +376,13 @@ const WorkspaceLabel = styled.span`
const SidebarTopActions = styled.div`
align-items: center;
- display: grid;
+ display: none;
gap: 2px;
grid-auto-flow: column;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ display: grid;
+ }
`;
const SidebarIconButton = styled.div`
@@ -314,12 +398,15 @@ const SidebarControls = styled.div`
align-items: center;
display: grid;
gap: 8px;
- grid-template-columns: auto 1fr;
+ grid-auto-flow: column;
+ grid-template-columns: auto;
+ justify-content: center;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
display: flex;
gap: 12px;
+ grid-auto-flow: row;
justify-content: space-between;
}
`;
@@ -328,10 +415,14 @@ const SegmentedRail = styled.div`
background: #fcfcfccc;
border: 1px solid ${COLORS.border};
border-radius: 40px;
- display: grid;
+ display: none;
gap: 2px;
grid-auto-flow: column;
padding: 3px;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ display: grid;
+ }
`;
const Segment = styled.div<{ $selected?: boolean }>`
@@ -389,7 +480,13 @@ const SidebarScroll = styled.div`
flex-direction: column;
gap: 2px;
min-height: 0;
- overflow: hidden;
+ overflow-x: hidden;
+ overflow-y: auto;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
`;
const SidebarSection = styled.div`
@@ -417,6 +514,7 @@ const SidebarItemRow = styled.div<{
$depth?: number;
$interactive?: boolean;
$withBranch?: boolean;
+ $highlighted?: boolean;
}>`
align-items: center;
background: ${({ $active }) =>
@@ -424,13 +522,25 @@ const SidebarItemRow = styled.div<{
border-radius: 4px;
display: grid;
gap: 0;
- grid-template-columns: ${({ $withBranch }) =>
- $withBranch ? '9px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'};
+ grid-template-columns: auto;
+ justify-content: center;
height: 28px;
- padding: 0 2px 0 ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`};
+ padding: 0;
position: relative;
text-decoration: none;
transition: background-color 0.14s ease;
+ animation: ${({ $highlighted }) =>
+ $highlighted
+ ? 'heroObjectAppearRow 1800ms cubic-bezier(0.34, 1.56, 0.64, 1) both'
+ : 'none'};
+ transform-origin: left center;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ grid-template-columns: ${({ $withBranch }) =>
+ $withBranch ? '9px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'};
+ justify-content: stretch;
+ padding: 0 2px 0 ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`};
+ }
&:hover {
background: ${({ $active, $interactive }) =>
@@ -438,6 +548,53 @@ const SidebarItemRow = styled.div<{
? VISUAL_TOKENS.background.transparent.medium
: 'transparent'};
}
+
+ @keyframes heroObjectAppearRow {
+ 0% {
+ background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0);
+ box-shadow:
+ 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0),
+ 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0);
+ opacity: 0;
+ transform: translateX(-32px) translateY(-6px) scale(0.6);
+ }
+ 16% {
+ background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.55);
+ box-shadow:
+ 0 0 0 6px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.4),
+ 0 12px 28px -6px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.55);
+ opacity: 1;
+ transform: translateX(0) translateY(0) scale(1.18);
+ }
+ 32% {
+ background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.42);
+ box-shadow:
+ 0 0 0 12px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.24),
+ 0 10px 22px -6px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.38);
+ transform: translateX(0) scale(0.97);
+ }
+ 50% {
+ background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.28);
+ box-shadow:
+ 0 0 0 18px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.12),
+ 0 6px 16px -6px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.22);
+ transform: translateX(0) scale(1.02);
+ }
+ 72% {
+ background: rgba(var(--hero-highlight-rgb, 237, 95, 0), 0.16);
+ box-shadow:
+ 0 0 0 22px rgba(var(--hero-highlight-rgb, 237, 95, 0), 0),
+ 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0);
+ transform: translateX(0) scale(1);
+ }
+ 100% {
+ background: ${VISUAL_TOKENS.background.transparent.medium};
+ box-shadow:
+ 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0),
+ 0 0 0 0 rgba(var(--hero-highlight-rgb, 237, 95, 0), 0);
+ transform: translateX(0) scale(1);
+ }
+ }
`;
const SidebarItemRowLink = styled.a<{
@@ -452,14 +609,21 @@ const SidebarItemRowLink = styled.a<{
border-radius: 4px;
display: grid;
gap: 0;
- grid-template-columns: ${({ $withBranch }) =>
- $withBranch ? '9px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'};
+ grid-template-columns: auto;
+ justify-content: center;
height: 28px;
- padding: 0 2px 0 ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`};
+ padding: 0;
position: relative;
text-decoration: none;
transition: background-color 0.14s ease;
+ @media (min-width: ${theme.breakpoints.md}px) {
+ grid-template-columns: ${({ $withBranch }) =>
+ $withBranch ? '9px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'};
+ justify-content: stretch;
+ padding: 0 2px 0 ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`};
+ }
+
&:hover {
background: ${({ $active, $interactive }) =>
$active || $interactive
@@ -472,8 +636,13 @@ const SidebarIconSurface = styled.div<{
$background: string;
$border: string;
$color: string;
+ $pulse?: boolean;
}>`
align-items: center;
+ animation: ${({ $pulse }) =>
+ $pulse
+ ? 'heroObjectAppearIcon 1400ms cubic-bezier(0.34, 1.7, 0.64, 1) both'
+ : 'none'};
background: ${({ $background }) => $background};
border: 1px solid ${({ $border }) => $border};
border-radius: 4px;
@@ -484,13 +653,35 @@ const SidebarIconSurface = styled.div<{
justify-content: center;
position: relative;
width: 16px;
+
+ @keyframes heroObjectAppearIcon {
+ 0% {
+ transform: scale(0.35) rotate(-18deg);
+ }
+ 30% {
+ transform: scale(1.45) rotate(8deg);
+ }
+ 55% {
+ transform: scale(0.9) rotate(-4deg);
+ }
+ 80% {
+ transform: scale(1.06) rotate(2deg);
+ }
+ 100% {
+ transform: scale(1) rotate(0deg);
+ }
+ }
`;
const SidebarItemText = styled.div`
- align-items: center;
- display: flex;
- gap: 2px;
+ display: none;
min-width: 0;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ align-items: center;
+ display: flex;
+ gap: 2px;
+ }
`;
const SidebarItemLabel = styled.span<{ $active?: boolean }>`
@@ -606,7 +797,7 @@ const SidebarAvatar = styled.div<{
const RightPane = styled.div`
display: flex;
- flex: 1 1 auto;
+ flex: 1 1 0;
flex-direction: column;
gap: 12px;
min-height: 0;
@@ -621,18 +812,22 @@ const RightPane = styled.div`
const NavbarBar = styled.div`
align-items: center;
background: transparent;
- display: flex;
+ display: grid;
flex: 0 0 32px;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
height: 32px;
- justify-content: space-between;
min-width: 0;
+ width: 100%;
`;
const Breadcrumb = styled.div`
align-items: center;
display: flex;
+ flex: 1 1 auto;
gap: 2px;
min-width: 0;
+ overflow: hidden;
`;
const BreadcrumbTag = styled.div`
@@ -659,12 +854,18 @@ const CrumbLabel = styled.span`
const NavbarActions = styled.div`
align-items: center;
display: flex;
+ flex: 0 1 auto;
gap: 8px;
+ justify-self: end;
+ max-width: 100%;
+ min-width: 0;
pointer-events: none;
`;
const DesktopOnlyNavbarAction = styled.div`
display: none;
+ flex: 0 1 auto;
+ min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
display: block;
@@ -679,13 +880,15 @@ const NavbarActionButton = styled.div<{ $iconOnly?: boolean }>`
border: 1px solid ${NAVBAR_ACTION_BORDER};
border-radius: ${VISUAL_TOKENS.border.radius.sm};
display: inline-flex;
+ flex: 0 1 auto;
font-family: ${APP_FONT};
font-size: ${VISUAL_TOKENS.font.size.md};
font-weight: ${VISUAL_TOKENS.font.weight.medium};
gap: ${VISUAL_TOKENS.spacing[1]};
height: 24px;
justify-content: center;
- min-width: ${({ $iconOnly }) => ($iconOnly ? '24px' : 'auto')};
+ min-width: ${({ $iconOnly }) => ($iconOnly ? '24px' : '0')};
+ max-width: 100%;
padding: ${({ $iconOnly }) =>
$iconOnly ? '0' : `0 ${VISUAL_TOKENS.spacing[2]}`};
white-space: nowrap;
@@ -705,9 +908,23 @@ const NavbarActionLabel = styled.span<{ $color?: string }>`
font-size: inherit;
font-weight: inherit;
line-height: 1.4;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
white-space: nowrap;
`;
+const DesktopOnlyNavbarTrailing = styled.div`
+ align-items: center;
+ display: none;
+ gap: ${VISUAL_TOKENS.spacing[1]};
+ height: 100%;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ display: inline-flex;
+ }
+`;
+
const NavbarActionSeparator = styled.div`
background: ${VISUAL_TOKENS.background.transparent.medium};
border-radius: 56px;
@@ -715,6 +932,38 @@ const NavbarActionSeparator = styled.div`
width: 1px;
`;
+// Pinned action buttons register to the left of the New Record button once
+// the chat reveals an object. Tighter padding/gap than the default navbar
+// buttons so multiple commands can sit side-by-side. Entrance animation
+// cascades left-to-right via --pinned-action-index so buttons feel like
+// they're landing one after the other.
+const PinnedActionButton = styled(NavbarActionButton)`
+ animation: pinnedActionIn 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
+ animation-delay: calc(var(--pinned-action-index, 0) * 90ms);
+ display: none;
+ gap: 4px;
+ padding: 0 6px;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ display: inline-flex;
+ }
+
+ @keyframes pinnedActionIn {
+ from {
+ opacity: 0;
+ transform: translateY(-6px) scale(0.94);
+ }
+ 60% {
+ opacity: 1;
+ transform: translateY(1px) scale(1.02);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+ }
+`;
+
const IndexSurface = styled.div`
background: ${COLORS.background};
border: 1px solid ${COLORS.border};
@@ -776,12 +1025,16 @@ const TinyDot = styled.div`
const ViewActions = styled.div`
align-items: center;
- display: flex;
+ display: none;
flex: 0 0 auto;
gap: 2px;
margin-left: auto;
position: relative;
z-index: 1;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ display: flex;
+ }
`;
const ViewAction = styled.span`
@@ -1149,11 +1402,15 @@ const FaviconImage = styled.img`
const TABLER_ICON_MAP: Record = {
book: IconBook,
buildingSkyscraper: IconBuildingSkyscraper,
+ calendarEvent: IconCalendarEvent,
checkbox: IconCheckbox,
folder: IconFolder,
layoutDashboard: IconLayoutDashboard,
+ mapPin: IconMapPin,
notes: IconNotes,
+ planet: IconPlanet,
playerPlay: IconPlayerPlay,
+ rocket: IconRocket,
settings: IconSettings,
settingsAutomation: IconSettingsAutomation,
targetArrow: IconTargetArrow,
@@ -1168,11 +1425,20 @@ const HEADER_ICON_MAP: Record = {
arr: IconMoneybag,
createdBy: IconCreativeCommonsSa,
employees: IconUsers,
+ heightMeters: IconRuler,
icp: IconTarget,
industry: IconBuildingFactory2,
+ launchDate: IconCalendarEvent,
linkedin: IconBrandLinkedin,
mainContact: IconUser,
+ manufacturer: IconBuildingFactory2,
+ massKg: IconWeight,
+ name: IconRocket,
opportunities: IconTargetArrow,
+ reusable: IconRefresh,
+ serialNumber: IconBarcode,
+ status: IconProgress,
+ targetOrbit: IconPlanet,
url: IconLink,
};
@@ -1294,10 +1560,17 @@ function findContainingFolderId(
function renderPageDefinition(
page: HeroPageDefinition,
onNavigateToLabel?: (label: string) => void,
+ pageKey?: string,
) {
switch (page.type) {
case 'table':
- return ;
+ return (
+
+ );
case 'kanban':
return PAGE_RENDERERS.kanban(page);
case 'dashboard':
@@ -1340,7 +1613,7 @@ function renderNavbarAction(
) : null}
@@ -1350,12 +1623,12 @@ function renderNavbarAction(
) : null}
{action.trailingLabel ? (
- <>
+
{action.trailingLabel}
- >
+
) : null}
);
@@ -1590,13 +1863,17 @@ function PersonAvatarContent({ token }: { token: HeroCellPerson }) {
return token.shortLabel ?? getInitials(token.name);
}
-function renderSidebarIcon(icon: HeroSidebarIcon): ReactNode {
+function renderSidebarIcon(
+ icon: HeroSidebarIcon,
+ pulse: boolean = false,
+): ReactNode {
if (icon.kind === 'brand') {
return (
{TablerIcon ? (
void;
onSelect?: (label: string) => void;
selectedLabel?: string;
+ highlightedItemId?: string;
}) {
const showBranch = depth > 0;
const rowSelectable = interactive && item.href === undefined && !collapsible;
@@ -1725,12 +2005,23 @@ function SidebarItemComponent({
rowSelectable &&
selectedLabel !== undefined &&
item.label === selectedLabel;
+ const rowHighlighted = highlightedItemId === item.id;
const childItems = item.children ?? [];
+ // Tint the appearance animation with the item's own tone so Rocket pops
+ // violet, Launch pops orange, Payload pops teal, etc.
+ const iconTone =
+ 'tone' in item.icon && typeof item.icon.tone === 'string'
+ ? item.icon.tone
+ : 'gray';
+ const highlightRgb = SIDEBAR_TONE_RGB[iconTone] ?? SIDEBAR_TONE_RGB.gray;
+ const highlightStyle = rowHighlighted
+ ? ({ '--hero-highlight-rgb': highlightRgb } as React.CSSProperties)
+ : undefined;
const rowContent = (
<>
{showBranch ? : null}
- {renderSidebarIcon(item.icon)}
+ {renderSidebarIcon(item.icon, rowHighlighted)}
{item.label}
{item.meta ? · {item.meta} : null}
@@ -1763,6 +2054,7 @@ function SidebarItemComponent({
onSelect?.(item.label)
: undefined
}
- style={{ cursor: rowInteractive ? 'pointer' : 'default' }}
+ style={{
+ cursor: rowInteractive ? 'pointer' : 'default',
+ ...highlightStyle,
+ }}
>
{rowContent}
@@ -1784,6 +2079,7 @@ function SidebarItemComponent({
(null);
-
const defaultActiveLabel =
visual.favoritesNav?.find((item) => item.active)?.label ??
visual.workspaceNav.find((entry) => !isFolder(entry) && entry.active)
@@ -2011,6 +2305,15 @@ export function HomeVisual({ visual }: { visual: HeroVisualType }) {
'';
const [activeLabel, setActiveLabel] = useState(defaultActiveLabel);
+ const [createdObjectIds, setCreatedObjectIds] = useState([]);
+ // Objects whose pinned navbar commands have been scaffolded by the chat.
+ // Includes Companies (reused from the standard sidebar) and all newly
+ // created CRM objects. The navbar looks up its pinned actions here so
+ // commands only appear after the assistant announces them.
+ const [revealedObjectIds, setRevealedObjectIds] = useState([]);
+ const [highlightedItemId, setHighlightedItemId] = useState(
+ null,
+ );
const [openFolderIds, setOpenFolderIds] = useState(() => {
const activeFolderId = findContainingFolderId(
visual.workspaceNav,
@@ -2029,7 +2332,6 @@ export function HomeVisual({ visual }: { visual: HeroVisualType }) {
return [];
});
});
- const [tilt, setTilt] = useState({ x: 0, y: 0 });
const pageDefaults = useMemo(
() => ({
defaultActions: visual.actions ?? [],
@@ -2038,13 +2340,80 @@ export function HomeVisual({ visual }: { visual: HeroVisualType }) {
[visual.actions, visual.tableWidth],
);
+ // Inject the CRM objects at the top of the workspace sidebar as they are
+ // "created" by the AI chat. The callback chain is:
+ // AssistantResponse -> ConversationPanel -> DraggableTerminal -> here. Each
+ // object streams in one-by-one from the first assistant paragraph; the
+ // workspace mirrors that order, with the most recently created object on top
+ // and surfaced as the active page.
+ const workspaceNav = useMemo(() => {
+ if (createdObjectIds.length === 0) {
+ return visual.workspaceNav;
+ }
+
+ // Walk the created list from newest-first so the last object streamed sits
+ // on top. Any CRM entry not yet created is simply skipped.
+ const prepended = [...createdObjectIds]
+ .reverse()
+ .map(
+ (id) =>
+ CRM_OBJECT_SEQUENCE.find((entry) => entry.id === id)?.sidebarItem,
+ )
+ .filter((item): item is NonNullable => item !== undefined);
+
+ return [...prepended, ...visual.workspaceNav];
+ }, [createdObjectIds, visual.workspaceNav]);
+
+ const handleObjectCreated = useCallback((id: string) => {
+ setRevealedObjectIds((current) =>
+ current.includes(id) ? current : [...current, id],
+ );
+ // Companies is reused from the standard sidebar — no prepend, just flash
+ // the existing item and show its index page.
+ if (id === COMPANIES_ITEM_ID) {
+ setActiveLabel(COMPANIES_ITEM_LABEL);
+ setHighlightedItemId(COMPANIES_ITEM_ID);
+ return;
+ }
+ const entry = CRM_OBJECT_SEQUENCE.find((candidate) => candidate.id === id);
+ if (!entry) {
+ return;
+ }
+ setCreatedObjectIds((current) =>
+ current.includes(id) ? current : [...current, id],
+ );
+ setActiveLabel(entry.label);
+ setHighlightedItemId(entry.id);
+ }, []);
+
+ const handleChatReset = useCallback(() => {
+ setCreatedObjectIds([]);
+ setRevealedObjectIds([]);
+ setHighlightedItemId(null);
+ setActiveLabel(defaultActiveLabel);
+ }, [defaultActiveLabel]);
+
+ const handleJumpToConversationEnd = useCallback(() => {
+ setCreatedObjectIds(COMPLETED_CREATED_OBJECT_IDS);
+ setRevealedObjectIds(COMPLETED_REVEALED_OBJECT_IDS);
+ setHighlightedItemId(null);
+ setActiveLabel(COMPLETED_ACTIVE_OBJECT_LABEL);
+ }, []);
+
+ useEffect(() => {
+ if (highlightedItemId === null) {
+ return undefined;
+ }
+ const id = window.setTimeout(() => setHighlightedItemId(null), 2000);
+ return () => window.clearTimeout(id);
+ }, [highlightedItemId]);
+
const activeItem = useMemo(
() =>
(visual.favoritesNav
? findActiveItem(visual.favoritesNav, activeLabel, pageDefaults)
- : undefined) ??
- findActiveItem(visual.workspaceNav, activeLabel, pageDefaults),
- [activeLabel, pageDefaults, visual.favoritesNav, visual.workspaceNav],
+ : undefined) ?? findActiveItem(workspaceNav, activeLabel, pageDefaults),
+ [activeLabel, pageDefaults, visual.favoritesNav, workspaceNav],
);
const activePage = useMemo(
() => (activeItem ? normalizeHeroPage(activeItem, pageDefaults) : null),
@@ -2053,6 +2422,13 @@ export function HomeVisual({ visual }: { visual: HeroVisualType }) {
const activeHeader = activePage?.header;
const activeActions = activeHeader?.actions ?? [];
const navbarActions = activeHeader?.navbarActions;
+ // Pinned commands registered by the active object. Surfaced only after the
+ // chat has revealed that object, mirroring how a real workspace would only
+ // gain header actions after the schema / command-menu-items lands.
+ const pinnedActions =
+ activeItem && revealedObjectIds.includes(activeItem.id)
+ ? OBJECT_PINNED_ACTIONS[activeItem.id]
+ : undefined;
const showPageCount = activeHeader?.count !== undefined;
const showListIcon = activeHeader?.showListIcon ?? false;
const showViewBar =
@@ -2061,29 +2437,10 @@ export function HomeVisual({ visual }: { visual: HeroVisualType }) {
activePage.type !== 'dashboard' &&
activePage.type !== 'workflow';
- const handleShellPointerMove = (event: ReactPointerEvent) => {
- if (event.pointerType !== 'mouse' || !shellRef.current) {
- return;
- }
-
- const bounds = shellRef.current.getBoundingClientRect();
- const horizontal = ((event.clientX - bounds.left) / bounds.width - 0.5) * 2;
- const vertical = ((event.clientY - bounds.top) / bounds.height - 0.5) * 2;
-
- setTilt({
- x: Number((-vertical * 2.2).toFixed(2)),
- y: Number((horizontal * 3.8).toFixed(2)),
- });
- };
-
- const resetTilt = () => {
- setTilt({ x: 0, y: 0 });
- };
-
const handleSelectLabel = (label: string) => {
setActiveLabel(label);
- const containingFolderId = findContainingFolderId(visual.workspaceNav, label);
+ const containingFolderId = findContainingFolderId(workspaceNav, label);
if (!containingFolderId) {
return;
@@ -2110,6 +2467,7 @@ export function HomeVisual({ visual }: { visual: HeroVisualType }) {
-
-
-
-
-
-
-
-
-
- {visual.workspace.name}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- New chat
-
-
-
-
- {visual.favoritesNav && visual.favoritesNav.length > 0 ? (
-
- Favorites
- {visual.favoritesNav.map((item) => (
-
+
+
+
+
+
+
+
+
- ))}
+
+ {visual.workspace.name}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ New chat
+
+
+
+
+ {visual.favoritesNav && visual.favoritesNav.length > 0 ? (
+
+ Favorites
+ {visual.favoritesNav.map((item) => (
+
+ ))}
+
+ ) : null}
+
+
+ Workspace
+
+ {workspaceNav.map(renderSidebarEntry)}
- ) : null}
-
-
- Workspace
-
- {visual.workspaceNav.map(renderSidebarEntry)}
-
-
-
+
+
-
-
-
-
- {activeItem ? renderSidebarIcon(activeItem.icon) : null}
- {activeLabel}
-
-
+
+
+
+
+ {activeItem ? renderSidebarIcon(activeItem.icon) : null}
+ {activeLabel}
+
+
-
- {navbarActions ? (
- navbarActions.map(renderNavbarAction)
- ) : (
- <>
-
+
+ {navbarActions ? (
+ navbarActions.map(renderNavbarAction)
+ ) : (
+ <>
+ {pinnedActions?.map((action, index) => (
+
+
+ {(() => {
+ const Icon =
+ NAVBAR_ACTION_ICON_MAP[action.icon] ??
+ IconPlus;
+ return (
+
+ );
+ })()}
+
+
+ {action.label}
+
+
+ ))}
+
+
+
+
+
+ New
+
+
-
- New Record
+
+
+
+ ⌘K
+
+
-
-
-
-
-
-
-
- ⌘K
-
-
- >
- )}
-
-
+ >
+ )}
+
+
-
- {showViewBar ? (
-
-
- {showListIcon ? (
- <>
- {activePage?.type === 'kanban' ? (
-
- ) : (
-
- )}
+
+ {showViewBar ? (
+
+
+ {showListIcon ? (
+ <>
+ {activePage?.type === 'kanban' ? (
+
+ ) : (
+
+ )}
+
+ {activeHeader?.title ?? activeLabel}
+
+ {showPageCount ? (
+ <>
+
+ {activeHeader?.count}
+
+ >
+ ) : null}
+ >
+ ) : (
{activeHeader?.title ?? activeLabel}
- {showPageCount ? (
- <>
-
- {activeHeader?.count}
-
- >
- ) : null}
- >
- ) : (
-
- {activeHeader?.title ?? activeLabel}
-
- )}
-
- {activeActions.length > 0 ? (
-
- {activeActions.map((action) => (
- {action}
- ))}
-
- ) : null}
-
- ) : null}
+ )}
+
+ {activeActions.length > 0 ? (
+
+ {activeActions.map((action) => (
+ {action}
+ ))}
+
+ ) : null}
+
+ ) : null}
- {activePage
- ? renderPageDefinition(activePage, handleSelectLabel)
- : null}
-
-
-
-
+ {activePage
+ ? renderPageDefinition(
+ activePage,
+ handleSelectLabel,
+ activeItem?.id ?? activeLabel,
+ )
+ : null}
+
+
+
+
+
+
);
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/TablePage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/TablePage.tsx
index 4d63cfc0a34..60d727ca058 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/TablePage.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/TablePage.tsx
@@ -148,11 +148,36 @@ const TableCanvas = styled.div<{ $width: number }>`
`;
const HeaderRow = styled.div`
+ animation: heroTableHeaderAppear 260ms ease-out both;
display: flex;
+
+ @keyframes heroTableHeaderAppear {
+ from {
+ opacity: 0;
+ transform: translateY(-2px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
`;
-const DataRow = styled.div`
+const DataRow = styled.div<{ $rowIndex: number }>`
+ animation: heroTableRowAppear 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
+ animation-delay: ${({ $rowIndex }) => `${120 + $rowIndex * 70}ms`};
display: flex;
+
+ @keyframes heroTableRowAppear {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
`;
const FooterRow = styled.div`
@@ -898,43 +923,6 @@ export function TablePage({
endDragging();
};
- useEffect(() => {
- const node = viewportRef.current;
-
- if (!node) {
- return;
- }
-
- const onWheel: EventListener = (event) => {
- if (!(event instanceof WheelEvent)) {
- return;
- }
-
- if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
- return;
- }
-
- const maxScrollLeft = Math.max(node.scrollWidth - node.clientWidth, 0);
- const nextScrollLeft = Math.min(
- Math.max(node.scrollLeft + event.deltaY, 0),
- maxScrollLeft,
- );
-
- if (Math.abs(nextScrollLeft - node.scrollLeft) < 0.5) {
- return;
- }
-
- node.scrollLeft = nextScrollLeft;
- event.preventDefault();
- };
-
- node.addEventListener('wheel', onWheel, { passive: false });
-
- return () => {
- node.removeEventListener('wheel', onWheel);
- };
- }, []);
-
return (
@@ -995,12 +983,13 @@ export function TablePage({
- {page.rows.map((row) => {
+ {page.rows.map((row, rowIndex) => {
const hovered = hoveredRowId === row.id;
return (
setHoveredRowId(row.id)}
onMouseLeave={() =>
setHoveredRowId((current) =>
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WindowOrder/WindowOrderProvider.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WindowOrder/WindowOrderProvider.tsx
new file mode 100644
index 00000000000..4d42ae6e656
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/WindowOrder/WindowOrderProvider.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
+
+// Z-order manager for the hero's floating windows. Each window registers on
+// mount and can call `activate()` to jump to the front on click. The provider
+// keeps a stack (first = back, last = front); z-indexes start at 2 so floating
+// windows always sit above the static scene.
+//
+// Split into two contexts on purpose:
+// - `Api` — stable callbacks (never changes identity) so `useEffect` in the
+// consumer only runs on mount / unmount.
+// - `Stack` — the reactive ordering; changes every activate, consumers
+// derive their z-index from it.
+// Without this split, `useEffect(() => context.register(id), [context])`
+// would unregister and re-register on every activate call (because the
+// memoized value reference changes), causing stack churn.
+
+type WindowOrderApi = {
+ register: (id: string) => void;
+ unregister: (id: string) => void;
+ activate: (id: string) => void;
+};
+
+const WindowOrderApiContext = createContext(null);
+const WindowOrderStackContext = createContext>([]);
+
+export const WindowOrderProvider = ({ children }: { children: ReactNode }) => {
+ const [stack, setStack] = useState([]);
+
+ const api = useMemo(
+ () => ({
+ register: (id) =>
+ setStack((previous) =>
+ previous.includes(id) ? previous : [...previous, id],
+ ),
+ unregister: (id) =>
+ setStack((previous) => previous.filter((item) => item !== id)),
+ activate: (id) =>
+ setStack((previous) => {
+ if (previous[previous.length - 1] === id) {
+ return previous;
+ }
+ return [...previous.filter((item) => item !== id), id];
+ }),
+ }),
+ [],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export const useWindowOrder = (id: string) => {
+ const api = useContext(WindowOrderApiContext);
+ const stack = useContext(WindowOrderStackContext);
+
+ useEffect(() => {
+ if (!api) {
+ return undefined;
+ }
+ api.register(id);
+ return () => {
+ api.unregister(id);
+ };
+ }, [api, id]);
+
+ const zIndex = useMemo(() => {
+ const index = stack.indexOf(id);
+ return index === -1 ? 1 : index + 2;
+ }, [stack, id]);
+
+ const activate = useCallback(() => {
+ api?.activate(id);
+ }, [api, id]);
+
+ return { activate, zIndex };
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/objectPinnedActions.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/objectPinnedActions.ts
new file mode 100644
index 00000000000..5b068849ab9
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/objectPinnedActions.ts
@@ -0,0 +1,55 @@
+import type { HeroNavbarActionType } from '../../types/HeroHomeData';
+
+// Each object registers its own pinned commands — these render to the left of
+// the New Record button once the chat has created/revealed the object. The
+// action shapes are intentionally identical to the real Twenty command-menu
+// item model (label + icon) so the mock lines up with what the assistant
+// announces in the chat paragraph. Kept in sync with the source files in
+// editorData.ts under src/command-menu-items/.
+
+type PinnedAction = HeroNavbarActionType;
+
+// Max 3 commands per object — reserved for the highest-value ops so the
+// header stays readable alongside the built-in New Record button.
+const ROCKET_PINNED_ACTIONS: PinnedAction[] = [
+ // "Fly again" leans into reusable-rocket ops: re-fly this vehicle on a new
+ // mission. Paired with launch scheduling and retirement to cover a rocket's
+ // full lifecycle (reuse / schedule / retire).
+ { icon: 'repeat', label: 'Fly again' },
+ { icon: 'calendarPlus', label: 'Schedule launch' },
+ { icon: 'playerPause', label: 'Retire' },
+];
+
+const LAUNCH_PINNED_ACTIONS: PinnedAction[] = [
+ { icon: 'calendarClock', label: 'Reschedule' },
+ { icon: 'box', label: 'Add payload' },
+ { icon: 'calendarEvent', label: 'Upcoming' },
+];
+
+const PAYLOAD_PINNED_ACTIONS: PinnedAction[] = [
+ // Action-oriented verb + space-ops framing: customers book launch slots.
+ // Scopes the quick-create to a customer-linked payload without leaning on
+ // "New" (which collides with the default New button).
+ { icon: 'calendarPlus', label: 'Book slot' },
+ { icon: 'flag', label: 'Set status' },
+];
+
+const COMPANIES_PINNED_ACTIONS: PinnedAction[] = [
+ { icon: 'flag', label: 'Set status' },
+];
+
+const LAUNCH_SITE_PINNED_ACTIONS: PinnedAction[] = [
+ // Status transitions (Active / Standby / Maintenance) are the most common
+ // site-level op; the other two are high-value jumps into related data.
+ { icon: 'flag', label: 'Set status' },
+ { icon: 'calendarPlus', label: 'Book window' },
+ { icon: 'rocket', label: 'Launches' },
+];
+
+export const OBJECT_PINNED_ACTIONS: Record = {
+ rockets: ROCKET_PINNED_ACTIONS,
+ launches: LAUNCH_PINNED_ACTIONS,
+ payloads: PAYLOAD_PINNED_ACTIONS,
+ companies: COMPANIES_PINNED_ACTIONS,
+ 'launch-sites': LAUNCH_SITE_PINNED_ACTIONS,
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/rocketObject.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/rocketObject.ts
new file mode 100644
index 00000000000..0d86baaa773
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/rocketObject.ts
@@ -0,0 +1,576 @@
+import type {
+ HeroSidebarItem,
+ HeroTablePageDefinition,
+} from '../../types/HeroHomeData';
+
+export const ROCKET_ITEM_ID = 'rockets';
+export const ROCKET_ITEM_LABEL = 'Rockets';
+
+const ROCKET_PAGE: HeroTablePageDefinition = {
+ type: 'table',
+ header: {
+ title: 'All Rockets',
+ count: 6,
+ },
+ columns: [
+ { id: 'name', label: 'Name', width: 200, isFirstColumn: true },
+ { id: 'serialNumber', label: 'Serial Number', width: 130 },
+ { id: 'manufacturer', label: 'Manufacturer', width: 180 },
+ { id: 'status', label: 'Status', width: 130 },
+ { id: 'reusable', label: 'Reusable', width: 110 },
+ { id: 'launchDate', label: 'Launch Date', width: 140 },
+ { id: 'targetOrbit', label: 'Target Orbit', width: 140 },
+ { id: 'heightMeters', label: 'Height (m)', width: 120, align: 'right' },
+ { id: 'massKg', label: 'Mass (kg)', width: 130, align: 'right' },
+ ],
+ rows: [
+ {
+ id: 'falcon-9',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Falcon 9',
+ shortLabel: 'F',
+ tone: 'blue',
+ },
+ serialNumber: { type: 'text', value: 'B1062' },
+ manufacturer: { type: 'entity', name: 'SpaceX', domain: 'spacex.com' },
+ status: { type: 'tag', value: 'Active' },
+ reusable: { type: 'boolean', value: true },
+ launchDate: { type: 'text', value: 'Apr 11, 2024' },
+ targetOrbit: { type: 'text', value: 'LEO' },
+ heightMeters: { type: 'number', value: '70' },
+ massKg: { type: 'number', value: '549,054' },
+ },
+ },
+ {
+ id: 'starship',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Starship',
+ shortLabel: 'S',
+ tone: 'amber',
+ },
+ serialNumber: { type: 'text', value: 'S29' },
+ manufacturer: { type: 'entity', name: 'SpaceX', domain: 'spacex.com' },
+ status: { type: 'tag', value: 'Testing' },
+ reusable: { type: 'boolean', value: true },
+ launchDate: { type: 'text', value: 'Jun 6, 2024' },
+ targetOrbit: { type: 'text', value: 'Mars Transfer' },
+ heightMeters: { type: 'number', value: '120' },
+ massKg: { type: 'number', value: '5,000,000' },
+ },
+ },
+ {
+ id: 'new-glenn',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'New Glenn',
+ shortLabel: 'N',
+ tone: 'teal',
+ },
+ serialNumber: { type: 'text', value: 'NG-1' },
+ manufacturer: {
+ type: 'entity',
+ name: 'Blue Origin',
+ domain: 'blueorigin.com',
+ },
+ status: { type: 'tag', value: 'Active' },
+ reusable: { type: 'boolean', value: true },
+ launchDate: { type: 'text', value: 'Jan 16, 2025' },
+ targetOrbit: { type: 'text', value: 'GTO' },
+ heightMeters: { type: 'number', value: '98' },
+ massKg: { type: 'number', value: '1,400,000' },
+ },
+ },
+ {
+ id: 'electron',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Electron',
+ shortLabel: 'E',
+ tone: 'purple',
+ },
+ serialNumber: { type: 'text', value: 'F52' },
+ manufacturer: {
+ type: 'entity',
+ name: 'Rocket Lab',
+ domain: 'rocketlabusa.com',
+ },
+ status: { type: 'tag', value: 'Active' },
+ reusable: { type: 'boolean', value: false },
+ launchDate: { type: 'text', value: 'Sep 20, 2024' },
+ targetOrbit: { type: 'text', value: 'SSO' },
+ heightMeters: { type: 'number', value: '18' },
+ massKg: { type: 'number', value: '13,000' },
+ },
+ },
+ {
+ id: 'ariane-6',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Ariane 6',
+ shortLabel: 'A',
+ tone: 'pink',
+ },
+ serialNumber: { type: 'text', value: 'VA262' },
+ manufacturer: {
+ type: 'entity',
+ name: 'Arianespace',
+ domain: 'arianespace.com',
+ },
+ status: { type: 'tag', value: 'Active' },
+ reusable: { type: 'boolean', value: false },
+ launchDate: { type: 'text', value: 'Jul 9, 2024' },
+ targetOrbit: { type: 'text', value: 'GTO' },
+ heightMeters: { type: 'number', value: '63' },
+ massKg: { type: 'number', value: '860,000' },
+ },
+ },
+ {
+ id: 'vulcan-centaur',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Vulcan Centaur',
+ shortLabel: 'V',
+ tone: 'green',
+ },
+ serialNumber: { type: 'text', value: 'VC-002' },
+ manufacturer: {
+ type: 'entity',
+ name: 'ULA',
+ domain: 'ulalaunch.com',
+ },
+ status: { type: 'tag', value: 'Active' },
+ reusable: { type: 'boolean', value: false },
+ launchDate: { type: 'text', value: 'Oct 4, 2024' },
+ targetOrbit: { type: 'text', value: 'GEO' },
+ heightMeters: { type: 'number', value: '62' },
+ massKg: { type: 'number', value: '546,700' },
+ },
+ },
+ ],
+};
+
+export const ROCKET_SIDEBAR_ITEM: HeroSidebarItem = {
+ id: ROCKET_ITEM_ID,
+ label: ROCKET_ITEM_LABEL,
+ icon: { kind: 'tabler', name: 'rocket', tone: 'violet' },
+ page: ROCKET_PAGE,
+};
+
+// -- Additional CRM objects --
+// Each object below gets its own empty-but-structured index view so the
+// workspace can reflect the schema as it's scaffolded by the chat.
+
+const LAUNCH_PAGE: HeroTablePageDefinition = {
+ type: 'table',
+ header: { title: 'Launches', count: 5 },
+ columns: [
+ { id: 'name', label: 'Name', width: 200, isFirstColumn: true },
+ { id: 'missionCode', label: 'Mission Code', width: 140 },
+ { id: 'status', label: 'Status', width: 130 },
+ { id: 'missionType', label: 'Mission Type', width: 140 },
+ { id: 'plannedLaunchAt', label: 'Planned Launch', width: 170 },
+ { id: 'rocket', label: 'Rocket', width: 170 },
+ { id: 'launchSite', label: 'Launch Site', width: 170 },
+ { id: 'actualLaunchAt', label: 'Actual Launch', width: 170 },
+ ],
+ rows: [
+ {
+ id: 'crs-29',
+ cells: {
+ name: { type: 'text', value: 'CRS-29', shortLabel: 'C', tone: 'blue' },
+ missionCode: { type: 'text', value: 'NASA-CRS-29' },
+ status: { type: 'tag', value: 'Success' },
+ missionType: { type: 'tag', value: 'Cargo' },
+ plannedLaunchAt: { type: 'text', value: 'Nov 9, 2023' },
+ rocket: {
+ type: 'relation',
+ items: [{ name: 'Falcon 9', shortLabel: 'F', tone: 'blue' }],
+ },
+ launchSite: {
+ type: 'relation',
+ items: [{ name: 'LC-39A', shortLabel: 'K', tone: 'red' }],
+ },
+ actualLaunchAt: { type: 'text', value: 'Nov 9, 2023' },
+ },
+ },
+ {
+ id: 'artemis-ii',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Artemis II',
+ shortLabel: 'A',
+ tone: 'amber',
+ },
+ missionCode: { type: 'text', value: 'NASA-ART-2' },
+ status: { type: 'tag', value: 'Scheduled' },
+ missionType: { type: 'tag', value: 'Crewed' },
+ plannedLaunchAt: { type: 'text', value: 'Sep 26, 2025' },
+ rocket: {
+ type: 'relation',
+ items: [{ name: 'SLS Block 1', shortLabel: 'S', tone: 'purple' }],
+ },
+ launchSite: {
+ type: 'relation',
+ items: [{ name: 'LC-39B', shortLabel: 'K', tone: 'red' }],
+ },
+ actualLaunchAt: { type: 'text', value: '—' },
+ },
+ },
+ {
+ id: 'ift-5',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Starship IFT-5',
+ shortLabel: 'S',
+ tone: 'amber',
+ },
+ missionCode: { type: 'text', value: 'SPX-IFT-5' },
+ status: { type: 'tag', value: 'Success' },
+ missionType: { type: 'tag', value: 'Test' },
+ plannedLaunchAt: { type: 'text', value: 'Oct 13, 2024' },
+ rocket: {
+ type: 'relation',
+ items: [{ name: 'Starship', shortLabel: 'S', tone: 'amber' }],
+ },
+ launchSite: {
+ type: 'relation',
+ items: [{ name: 'Starbase', shortLabel: 'S', tone: 'orange' }],
+ },
+ actualLaunchAt: { type: 'text', value: 'Oct 13, 2024' },
+ },
+ },
+ {
+ id: 'euclid-launch',
+ cells: {
+ name: { type: 'text', value: 'Euclid', shortLabel: 'E', tone: 'teal' },
+ missionCode: { type: 'text', value: 'ESA-EUC-1' },
+ status: { type: 'tag', value: 'Success' },
+ missionType: { type: 'tag', value: 'Commercial' },
+ plannedLaunchAt: { type: 'text', value: 'Jul 1, 2023' },
+ rocket: {
+ type: 'relation',
+ items: [{ name: 'Falcon 9', shortLabel: 'F', tone: 'blue' }],
+ },
+ launchSite: {
+ type: 'relation',
+ items: [{ name: 'SLC-40', shortLabel: 'C', tone: 'red' }],
+ },
+ actualLaunchAt: { type: 'text', value: 'Jul 1, 2023' },
+ },
+ },
+ {
+ id: 'psyche-launch',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Psyche',
+ shortLabel: 'P',
+ tone: 'purple',
+ },
+ missionCode: { type: 'text', value: 'NASA-PSY-1' },
+ status: { type: 'tag', value: 'Success' },
+ missionType: { type: 'tag', value: 'Commercial' },
+ plannedLaunchAt: { type: 'text', value: 'Oct 13, 2023' },
+ rocket: {
+ type: 'relation',
+ items: [
+ { name: 'Falcon Heavy', shortLabel: 'F', tone: 'blue' },
+ ],
+ },
+ launchSite: {
+ type: 'relation',
+ items: [{ name: 'LC-39A', shortLabel: 'K', tone: 'red' }],
+ },
+ actualLaunchAt: { type: 'text', value: 'Oct 13, 2023' },
+ },
+ },
+ ],
+};
+
+const PAYLOAD_PAGE: HeroTablePageDefinition = {
+ type: 'table',
+ header: { title: 'Payloads', count: 5 },
+ columns: [
+ { id: 'name', label: 'Name', width: 200, isFirstColumn: true },
+ { id: 'payloadType', label: 'Payload Type', width: 150 },
+ { id: 'status', label: 'Status', width: 130 },
+ { id: 'customer', label: 'Customer', width: 170 },
+ { id: 'launch', label: 'Launch', width: 170 },
+ { id: 'targetOrbit', label: 'Target Orbit', width: 150 },
+ { id: 'massKg', label: 'Mass (kg)', width: 130, align: 'right' },
+ ],
+ rows: [
+ {
+ id: 'starlink-batch-29',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Starlink v2 #29',
+ shortLabel: 'S',
+ tone: 'blue',
+ },
+ payloadType: { type: 'tag', value: 'Satellite' },
+ status: { type: 'tag', value: 'Deployed' },
+ customer: {
+ type: 'relation',
+ items: [{ name: 'Starlink', shortLabel: 'S', tone: 'blue' }],
+ },
+ launch: {
+ type: 'relation',
+ items: [{ name: 'Starlink-Grp-6-20', shortLabel: 'S', tone: 'blue' }],
+ },
+ targetOrbit: { type: 'text', value: 'LEO 550km' },
+ massKg: { type: 'number', value: '18,000' },
+ },
+ },
+ {
+ id: 'orion-artemis',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Orion Capsule',
+ shortLabel: 'O',
+ tone: 'amber',
+ },
+ payloadType: { type: 'tag', value: 'Crew Capsule' },
+ status: { type: 'tag', value: 'Integrated' },
+ customer: {
+ type: 'relation',
+ items: [{ name: 'NASA', shortLabel: 'N', tone: 'red' }],
+ },
+ launch: {
+ type: 'relation',
+ items: [{ name: 'Artemis II', shortLabel: 'A', tone: 'amber' }],
+ },
+ targetOrbit: { type: 'text', value: 'Lunar Transit' },
+ massKg: { type: 'number', value: '22,000' },
+ },
+ },
+ {
+ id: 'dragon-crs-29',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Dragon CRS-29',
+ shortLabel: 'D',
+ tone: 'blue',
+ },
+ payloadType: { type: 'tag', value: 'Cargo' },
+ status: { type: 'tag', value: 'Launched' },
+ customer: {
+ type: 'relation',
+ items: [{ name: 'NASA', shortLabel: 'N', tone: 'red' }],
+ },
+ launch: {
+ type: 'relation',
+ items: [{ name: 'CRS-29', shortLabel: 'C', tone: 'blue' }],
+ },
+ targetOrbit: { type: 'text', value: 'LEO' },
+ massKg: { type: 'number', value: '12,500' },
+ },
+ },
+ {
+ id: 'psyche-probe',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Psyche Probe',
+ shortLabel: 'P',
+ tone: 'purple',
+ },
+ payloadType: { type: 'tag', value: 'Probe' },
+ status: { type: 'tag', value: 'Deployed' },
+ customer: {
+ type: 'relation',
+ items: [{ name: 'NASA', shortLabel: 'N', tone: 'red' }],
+ },
+ launch: {
+ type: 'relation',
+ items: [{ name: 'Psyche', shortLabel: 'P', tone: 'purple' }],
+ },
+ targetOrbit: { type: 'text', value: 'Asteroid belt' },
+ massKg: { type: 'number', value: '2,747' },
+ },
+ },
+ {
+ id: 'euclid-observatory',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Euclid Observatory',
+ shortLabel: 'E',
+ tone: 'teal',
+ },
+ payloadType: { type: 'tag', value: 'Satellite' },
+ status: { type: 'tag', value: 'Deployed' },
+ customer: {
+ type: 'relation',
+ items: [{ name: 'ESA', shortLabel: 'E', tone: 'teal' }],
+ },
+ launch: {
+ type: 'relation',
+ items: [{ name: 'Euclid', shortLabel: 'E', tone: 'teal' }],
+ },
+ targetOrbit: { type: 'text', value: 'Sun-Earth L2' },
+ massKg: { type: 'number', value: '2,160' },
+ },
+ },
+ ],
+};
+
+const LAUNCH_SITE_PAGE: HeroTablePageDefinition = {
+ type: 'table',
+ header: { title: 'Launch sites', count: 5 },
+ columns: [
+ { id: 'name', label: 'Name', width: 220, isFirstColumn: true },
+ { id: 'siteCode', label: 'Site Code', width: 140 },
+ { id: 'padName', label: 'Pad Name', width: 180 },
+ { id: 'country', label: 'Country', width: 140 },
+ { id: 'siteStatus', label: 'Site Status', width: 150 },
+ ],
+ rows: [
+ {
+ id: 'ksc-39a',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Kennedy LC-39A',
+ shortLabel: 'K',
+ tone: 'red',
+ },
+ siteCode: { type: 'text', value: 'KSC-39A' },
+ padName: { type: 'text', value: 'Launch Complex 39A' },
+ country: { type: 'text', value: 'United States' },
+ siteStatus: { type: 'tag', value: 'Active' },
+ },
+ },
+ {
+ id: 'ccsfs-slc-40',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Cape Canaveral SLC-40',
+ shortLabel: 'C',
+ tone: 'red',
+ },
+ siteCode: { type: 'text', value: 'CCSFS-40' },
+ padName: { type: 'text', value: 'Space Launch Complex 40' },
+ country: { type: 'text', value: 'United States' },
+ siteStatus: { type: 'tag', value: 'Active' },
+ },
+ },
+ {
+ id: 'starbase',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Starbase',
+ shortLabel: 'S',
+ tone: 'orange',
+ },
+ siteCode: { type: 'text', value: 'SB-01' },
+ padName: { type: 'text', value: 'Orbital Launch Pad A' },
+ country: { type: 'text', value: 'United States' },
+ siteStatus: { type: 'tag', value: 'Active' },
+ },
+ },
+ {
+ id: 'vandenberg-slc-4e',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Vandenberg SLC-4E',
+ shortLabel: 'V',
+ tone: 'purple',
+ },
+ siteCode: { type: 'text', value: 'VSFB-4E' },
+ padName: { type: 'text', value: 'Space Launch Complex 4E' },
+ country: { type: 'text', value: 'United States' },
+ siteStatus: { type: 'tag', value: 'Active' },
+ },
+ },
+ {
+ id: 'kourou-ela-4',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Kourou ELA-4',
+ shortLabel: 'K',
+ tone: 'teal',
+ },
+ siteCode: { type: 'text', value: 'CSG-4' },
+ padName: { type: 'text', value: 'Ensemble de Lancement 4' },
+ country: { type: 'text', value: 'French Guiana' },
+ siteStatus: { type: 'tag', value: 'Active' },
+ },
+ },
+ ],
+};
+
+export const LAUNCH_SIDEBAR_ITEM: HeroSidebarItem = {
+ id: 'launches',
+ label: 'Launches',
+ icon: { kind: 'tabler', name: 'calendarEvent', tone: 'violet' },
+ page: LAUNCH_PAGE,
+};
+
+export const PAYLOAD_SIDEBAR_ITEM: HeroSidebarItem = {
+ id: 'payloads',
+ label: 'Payloads',
+ icon: { kind: 'tabler', name: 'planet', tone: 'violet' },
+ page: PAYLOAD_PAGE,
+};
+
+export const LAUNCH_SITE_SIDEBAR_ITEM: HeroSidebarItem = {
+ id: 'launch-sites',
+ label: 'Launch sites',
+ icon: { kind: 'tabler', name: 'mapPin', tone: 'violet' },
+ page: LAUNCH_SITE_PAGE,
+};
+
+// Id used by the chat to highlight the already-existing standard Companies
+// sidebar item (we reuse it as the customer object instead of creating a new
+// Customer object).
+export const COMPANIES_ITEM_ID = 'companies';
+export const COMPANIES_ITEM_LABEL = 'Companies';
+
+// Ordered pairs used by the chat stream to append objects to the sidebar in
+// the same order they're mentioned in the assistant's first paragraph. The
+// Customer step is intentionally absent — it reuses the standard Companies
+// object that's already in the sidebar.
+export const CRM_OBJECT_SEQUENCE: ReadonlyArray<{
+ id: string;
+ label: string;
+ sidebarItem: HeroSidebarItem;
+}> = [
+ {
+ id: ROCKET_ITEM_ID,
+ label: ROCKET_ITEM_LABEL,
+ sidebarItem: ROCKET_SIDEBAR_ITEM,
+ },
+ {
+ id: LAUNCH_SIDEBAR_ITEM.id,
+ label: LAUNCH_SIDEBAR_ITEM.label,
+ sidebarItem: LAUNCH_SIDEBAR_ITEM,
+ },
+ {
+ id: PAYLOAD_SIDEBAR_ITEM.id,
+ label: PAYLOAD_SIDEBAR_ITEM.label,
+ sidebarItem: PAYLOAD_SIDEBAR_ITEM,
+ },
+ {
+ id: LAUNCH_SITE_SIDEBAR_ITEM.id,
+ label: LAUNCH_SITE_SIDEBAR_ITEM.label,
+ sidebarItem: LAUNCH_SITE_SIDEBAR_ITEM,
+ },
+];
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/windowShadows.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/windowShadows.ts
new file mode 100644
index 00000000000..284c0b6daa3
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/windowShadows.ts
@@ -0,0 +1,16 @@
+// Shared box-shadow tokens for floating windows inside the hero (Terminal +
+// Twenty app window). Defined once so both shells stay visually consistent.
+export const WINDOW_SHADOWS = {
+ // Default resting state — the window is on-screen but not being interacted
+ // with and not the frontmost window.
+ resting: '0 10px 64px 0 rgba(0, 0, 0, 0.2)',
+ // Elevated state — applied while the window is being dragged or resized, or
+ // when it's the active / frontmost window. Slightly stronger diffuse shadow
+ // plus a tight close shadow for depth.
+ elevated:
+ '0 14px 80px 0 rgba(0, 0, 0, 0.26), 0 4px 12px 0 rgba(0, 0, 0, 0.08)',
+ // Mobile resting shadow — lighter so the stacked window underneath isn't
+ // washed out to a grey by the overlapping shadow.
+ mobileResting: '0 4px 16px 0 rgba(0, 0, 0, 0.08)',
+ mobileElevated: '0 6px 20px 0 rgba(0, 0, 0, 0.12)',
+} as const;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/Root/Root.tsx b/packages/twenty-website-new/src/sections/Hero/components/Root/Root.tsx
index 4ad026cf92b..ee53b06df8a 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/Root/Root.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/Root/Root.tsx
@@ -1,23 +1,41 @@
import { Container } from '@/design-system/components';
+import { IllustrationMount } from '@/illustrations';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
const StyledSection = styled.section`
+ background-image: url('/images/shared/light-noise.webp');
min-width: 0;
+ overflow: clip;
+ padding-bottom: ${theme.spacing(6)};
+ position: relative;
width: 100%;
`;
+const StyledBackground = styled.div`
+ bottom: 0;
+ left: -20%;
+ overflow: clip;
+ pointer-events: none;
+ position: absolute;
+ right: -20%;
+ top: 0;
+ z-index: 0;
+`;
+
const StyledContainer = styled(Container)`
display: grid;
grid-template-columns: minmax(0, 1fr);
justify-items: center;
min-width: 0;
+ position: relative;
text-align: center;
padding-top: ${theme.spacing(7.5)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
row-gap: ${theme.spacing(6)};
+ z-index: 1;
@media (min-width: ${theme.breakpoints.md}px) {
padding-top: ${theme.spacing(12)};
@@ -26,11 +44,24 @@ const StyledContainer = styled(Container)`
}
`;
-type RootProps = { backgroundColor: string; children: ReactNode };
+type RootProps = {
+ backgroundColor: string;
+ children: ReactNode;
+ showHomeBackground?: boolean;
+};
-export function Root({ backgroundColor, children }: RootProps) {
+export function Root({
+ backgroundColor,
+ children,
+ showHomeBackground = false,
+}: RootProps) {
return (
+ {showHomeBackground ? (
+
+
+
+ ) : null}
{children}
);
diff --git a/packages/twenty-website-new/src/sections/HomeStepper/components/Visual/StepperBackgroundHalftone.tsx b/packages/twenty-website-new/src/sections/HomeStepper/components/Visual/StepperBackgroundHalftone.tsx
index bd12ae0d393..0ea3e0a618d 100644
--- a/packages/twenty-website-new/src/sections/HomeStepper/components/Visual/StepperBackgroundHalftone.tsx
+++ b/packages/twenty-website-new/src/sections/HomeStepper/components/Visual/StepperBackgroundHalftone.tsx
@@ -17,24 +17,25 @@ const HOVER_FADE_OUT = 7;
const HALFTONE_SETTINGS = {
animation: {
- hoverHalftoneEnabled: false,
- hoverHalftonePowerShift: 0.42,
- hoverHalftoneRadius: 0.45,
+ hoverHalftoneEnabled: true,
+ hoverHalftonePowerShift: 0.62,
+ hoverHalftoneRadius: 0.6,
hoverHalftoneWidthShift: -0.18,
- hoverLightEnabled: true,
- hoverLightIntensity: 0.35,
- hoverLightRadius: 0.42,
+ hoverLightEnabled: false,
+ hoverLightIntensity: 0.12,
+ hoverLightRadius: 0.8,
waveAmount: 2,
waveEnabled: false,
waveSpeed: 1,
},
halftone: {
- dashColor: '#868686',
- hoverDashColor: '#F5F5F5',
- imageContrast: 1,
- power: 0.5,
- scale: 8,
- width: 0.3,
+ dashColor: '#dddddd',
+ hoverDashColor: '#FFF',
+ imageContrast: 1.12,
+ minimumTone: 0.26,
+ power: 0.18,
+ scale: 12,
+ width: 0.72,
},
};
@@ -95,6 +96,7 @@ const halftoneFragmentShader = /* glsl */ `
uniform float s_4;
uniform vec3 dashColor;
uniform vec3 hoverDashColor;
+ uniform float minimumTone;
uniform float time;
uniform float waveAmount;
uniform float waveSpeed;
@@ -208,15 +210,17 @@ const halftoneFragmentShader = /* glsl */ `
);
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
- float bandRadius = clamp(
+ float tonalAverage = (
(
sceneSample.r +
sceneSample.g +
sceneSample.b +
localPower * length(vec2(0.5))
) *
- (1.0 / 3.0) +
- lightLift,
+ (1.0 / 3.0)
+ ) + lightLift;
+ float bandRadius = clamp(
+ max(tonalAverage, minimumTone),
0.0,
1.0
) * 1.86 * 0.5;
@@ -394,6 +398,7 @@ async function mountHalftoneCanvas({
logicalResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
+ minimumTone: { value: HALFTONE_SETTINGS.halftone.minimumTone },
s_3: { value: HALFTONE_SETTINGS.halftone.power },
s_4: { value: HALFTONE_SETTINGS.halftone.width },
tScene: { value: sceneTarget.texture },
diff --git a/packages/twenty-website-new/src/sections/HomeStepper/components/Visual/StepperLottie.tsx b/packages/twenty-website-new/src/sections/HomeStepper/components/Visual/StepperLottie.tsx
index bf57f650f60..0bab30f5d1a 100644
--- a/packages/twenty-website-new/src/sections/HomeStepper/components/Visual/StepperLottie.tsx
+++ b/packages/twenty-website-new/src/sections/HomeStepper/components/Visual/StepperLottie.tsx
@@ -13,7 +13,8 @@ import {
import { scrollProgressToHomeStepperLottieFrame } from '@/sections/HomeStepper/utils/home-stepper-lottie-frame-map';
import { theme } from '@/theme';
-export const HOME_STEPPER_LOTTIE_SRC = '/lottie/stepper/stepper.lottie';
+export const HOME_STEPPER_LOTTIE_SRC =
+ '/lottie/stepper/stepper.lottie?v=data-model-icon-white-3';
const LottieSlot = styled.div`
box-sizing: border-box;
diff --git a/packages/twenty-website-new/src/sections/Menu/components/Cta/Cta.tsx b/packages/twenty-website-new/src/sections/Menu/components/Cta/Cta.tsx
index 9ea7f18eece..c64227f47a1 100644
--- a/packages/twenty-website-new/src/sections/Menu/components/Cta/Cta.tsx
+++ b/packages/twenty-website-new/src/sections/Menu/components/Cta/Cta.tsx
@@ -27,6 +27,7 @@ export function Cta({ scheme }: CtaProps) {
color={buttonColor}
href="https://app.twenty.com/welcome"
label="Log in"
+ size="small"
type="anchor"
variant="outlined"
/>
@@ -34,6 +35,7 @@ export function Cta({ scheme }: CtaProps) {
color={buttonColor}
href="https://app.twenty.com/welcome"
label="Get started"
+ size="small"
type="anchor"
variant="contained"
/>
diff --git a/packages/twenty-website-new/src/sections/Menu/components/Root/Root.tsx b/packages/twenty-website-new/src/sections/Menu/components/Root/Root.tsx
index 3c2f4d23cba..6898f821333 100644
--- a/packages/twenty-website-new/src/sections/Menu/components/Root/Root.tsx
+++ b/packages/twenty-website-new/src/sections/Menu/components/Root/Root.tsx
@@ -18,6 +18,7 @@ const SCROLL_IDLE_TIMEOUT_MS = 150;
const StyledSection = styled.section<{ $isScrolling: boolean }>`
backdrop-filter: blur(10px);
+ background-image: url('/images/shared/light-noise.webp');
box-shadow: ${({ $isScrolling }) =>
$isScrolling
? '0 1px 3px 0 rgba(0, 0, 0, 0.06)'
diff --git a/packages/twenty-website-new/src/sections/Problem/components/Root/Root.tsx b/packages/twenty-website-new/src/sections/Problem/components/Root/Root.tsx
index 5c2761dbc4f..9d387ceb8f8 100644
--- a/packages/twenty-website-new/src/sections/Problem/components/Root/Root.tsx
+++ b/packages/twenty-website-new/src/sections/Problem/components/Root/Root.tsx
@@ -14,14 +14,14 @@ const StyledContainer = styled(Container)`
padding-bottom: ${theme.spacing(12)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
- padding-top: ${theme.spacing(12)};
+ padding-top: ${theme.spacing(6)};
@media (min-width: ${theme.breakpoints.md}px) {
column-gap: ${theme.spacing(4)};
grid-template-columns: 1fr 1fr;
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
- padding-top: ${theme.spacing(10)};
+ padding-top: ${theme.spacing(6)};
padding-bottom: ${theme.spacing(10)};
row-gap: ${theme.spacing(20)};
}
diff --git a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataHeroTable.tsx b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataHeroTable.tsx
index 1f974de82c0..2ac6511df1d 100644
--- a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataHeroTable.tsx
+++ b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataHeroTable.tsx
@@ -1,13 +1,14 @@
'use client';
+import { SHARED_COMPANY_LOGO_URLS } from '@/lib/shared-asset-paths';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
- IconAt,
+ IconBuildingSkyscraper,
IconCheckbox,
IconChevronDown,
+ IconLink,
IconPlus,
- IconUser,
} from '@tabler/icons-react';
import {
useEffect,
@@ -31,8 +32,6 @@ const ROW_ENTER_STAGGER_MS = 160;
const COLORS = {
accentBorder: VISUAL_TOKENS.border.color.blue,
accentSurfaceSoft: VISUAL_TOKENS.background.transparent.blue,
- avatarBackground: '#1f1f1f',
- avatarText: '#ffffff',
background: VISUAL_TOKENS.background.primary,
backgroundSecondary: VISUAL_TOKENS.background.secondary,
borderLight: VISUAL_TOKENS.border.color.light,
@@ -51,67 +50,67 @@ const COLORS = {
} as const;
const TABLE_COLUMNS = [
- { id: 'contact', isFirstColumn: true, label: 'Contacts', width: 180 },
- { id: 'segment', isFirstColumn: false, label: 'Segment', width: 132 },
- { id: 'email', isFirstColumn: false, label: 'Email', width: 190 },
+ { id: 'company', isFirstColumn: true, label: 'Companies', width: 180 },
+ { id: 'type', isFirstColumn: false, label: 'Type', width: 132 },
+ { id: 'url', isFirstColumn: false, label: 'Domain', width: 150 },
] as const;
type TableRow = {
- contact: string;
- email: string;
- initials: string;
+ company: string;
+ domain: string;
isNew?: boolean;
- segment: string;
+ logoSrc: string;
+ status: string;
};
const BASE_TABLE_ROWS: ReadonlyArray = [
{
- contact: 'Ava Martinez',
- email: 'ava@resend.com',
- initials: 'AM',
- segment: 'VIP',
+ company: 'Anthropic',
+ domain: 'anthropic.com',
+ logoSrc: SHARED_COMPANY_LOGO_URLS.anthropic,
+ status: 'Customer',
},
{
- contact: 'Noah Kim',
- email: 'noah@resend.com',
- initials: 'NK',
- segment: 'Champion',
+ company: 'Slack',
+ domain: 'slack.com',
+ logoSrc: SHARED_COMPANY_LOGO_URLS.slack,
+ status: 'Customer',
},
{
- contact: 'Lena Patel',
- email: 'lena@resend.com',
- initials: 'LP',
- segment: 'Champion',
+ company: 'Notion',
+ domain: 'notion.so',
+ logoSrc: SHARED_COMPANY_LOGO_URLS.notion,
+ status: 'Customer',
},
{
- contact: 'Miles Chen',
- email: 'miles@resend.com',
- initials: 'MC',
- segment: 'Warm',
+ company: 'Sequoia',
+ domain: 'sequoiacap.com',
+ logoSrc: SHARED_COMPANY_LOGO_URLS.sequoia,
+ status: 'Customer',
},
{
- contact: 'Zoe Rivera',
- email: 'zoe@resend.com',
- initials: 'ZR',
- segment: 'Warm',
+ company: 'Cursor',
+ domain: 'cursor.com',
+ logoSrc: SHARED_COMPANY_LOGO_URLS.cursor,
+ status: 'Customer',
},
];
const EXPANDED_TABLE_ROWS: ReadonlyArray = [
...BASE_TABLE_ROWS,
{
- contact: 'Eli Brooks',
- email: 'eli@resend.com',
- initials: 'EB',
+ company: 'Twenty',
+ domain: 'twenty.com',
isNew: true,
- segment: 'Prospect',
+ logoSrc: SHARED_COMPANY_LOGO_URLS.twenty,
+ status: 'Customer',
},
{
- contact: 'Ivy Foster',
- email: 'ivy@resend.com',
- initials: 'IF',
+ company: 'Linear',
+ domain: 'linear.app',
isNew: true,
- segment: 'New',
+ logoSrc: SHARED_COMPANY_LOGO_URLS.linear,
+ status: 'Customer',
},
];
@@ -333,27 +332,17 @@ const LogoBase = styled.div`
align-items: center;
display: inline-flex;
flex: 0 0 auto;
- height: 16px;
+ height: 14px;
justify-content: center;
overflow: hidden;
- width: 16px;
+ width: 14px;
`;
-const ContactAvatar = styled.div`
- align-items: center;
- background: ${COLORS.avatarBackground};
- border-radius: 999px;
- color: ${COLORS.avatarText};
- display: inline-flex;
- font-family: ${APP_FONT};
- font-size: 8px;
- font-weight: ${theme.font.weight.medium};
- height: 16px;
- justify-content: center;
- letter-spacing: 0.02em;
- line-height: 1;
- text-transform: uppercase;
- width: 16px;
+const CompanyLogoImage = styled.img`
+ display: block;
+ height: 100%;
+ object-fit: contain;
+ width: 14px;
`;
const StatusChip = styled.div<{ $edited?: boolean; $hoveredByAlice?: boolean }>`
@@ -389,9 +378,9 @@ function HeaderIcon({
}: {
columnId: (typeof TABLE_COLUMNS)[number]['id'];
}) {
- if (columnId === 'email') {
+ if (columnId === 'url') {
return (
-
@@ -454,7 +443,12 @@ function ContactCell({ initials, label }: { initials: string; label: string }) {
label={label}
leftComponent={
- {initials}
+
}
variant={ChipVariant.Highlighted}
@@ -590,7 +584,7 @@ export function LiveDataHeroTable({
setHoveredRowIndex(index)}
onMouseLeave={() =>
setHoveredRowIndex((current) =>
@@ -658,7 +652,7 @@ export function LiveDataHeroTable({
$width={TABLE_COLUMNS[0].width}
>
-
+
@@ -669,13 +663,13 @@ export function LiveDataHeroTable({
>
{index === 0 && isFirstTagEdited
? editedStatusLabel
- : row.segment}
+ : row.status}
-
+
diff --git a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataVisual.tsx b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataVisual.tsx
index a963d56ac91..23d9127b289 100644
--- a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataVisual.tsx
+++ b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataVisual.tsx
@@ -3,11 +3,11 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
- IconBuildingSkyscraper,
IconChevronDown,
+ IconHeartHandshake,
IconList,
- IconMail,
IconPlus,
+ IconUser,
IconX,
} from '@tabler/icons-react';
import { type RefObject, useEffect, useRef, useState } from 'react';
@@ -181,7 +181,9 @@ const TablePanel = styled.div<{ $active?: boolean }>`
position: absolute;
right: 0px;
transform: ${({ $active }) =>
- `translate3d(0, 0, 0) scale(${$active ? TABLE_PANEL_HOVER_SCALE : 1})`};
+ `translate3d(0, 0, 0) scale(${
+ $active ? TABLE_PANEL_HOVER_SCALE : 1
+ })`};
transform-origin: bottom right;
transition:
box-shadow 260ms cubic-bezier(0.22, 1, 0.36, 1),
@@ -708,8 +710,8 @@ export function LiveDataVisual({
pointerTargetRef,
}: LiveDataVisualProps) {
const rootRef = useRef(null);
- const companyFilterRef = useRef(null);
- const opensFilterRef = useRef(null);
+ const typeFilterRef = useRef(null);
+ const employeesFilterRef = useRef(null);
const [isBobHovered, setIsBobHovered] = useState(false);
const [isTomHovered, setIsTomHovered] = useState(false);
const [phase, setPhase] = useState('idle');
@@ -781,18 +783,23 @@ export function LiveDataVisual({
useEffect(() => {
const measureAddFilterLefts = () => {
- const companyFilter = companyFilterRef.current;
- const opensFilter = opensFilterRef.current;
+ const typeFilter = typeFilterRef.current;
+ const employeesFilter = employeesFilterRef.current;
- if (!companyFilter || !opensFilter || opensFilter.offsetWidth === 0) {
+ if (
+ !typeFilter ||
+ !employeesFilter ||
+ employeesFilter.offsetWidth === 0
+ ) {
return;
}
const nextLefts = {
- docked:
- companyFilter.offsetLeft + companyFilter.offsetWidth + FILTER_ROW_GAP,
+ docked: typeFilter.offsetLeft + typeFilter.offsetWidth + FILTER_ROW_GAP,
parked:
- opensFilter.offsetLeft + opensFilter.offsetWidth + FILTER_ROW_GAP,
+ employeesFilter.offsetLeft +
+ employeesFilter.offsetWidth +
+ FILTER_ROW_GAP,
};
setAddFilterLefts((current) =>
@@ -830,10 +837,10 @@ export function LiveDataVisual({
phase === 'return-bob' ||
phase === 'settle';
const isBobCursorVisible = active;
- const isOpensFilterRemoving = phase === 'remove-filter';
- const isOpensFilterVisible =
+ const isEmployeesFilterRemoving = phase === 'remove-filter';
+ const isEmployeesFilterVisible =
phase !== 'remove-filter' && phase !== 'return-bob' && phase !== 'settle';
- const hasOpensFilterBeenRemoved =
+ const hasEmployeesFilterBeenRemoved =
phase === 'remove-filter' || phase === 'return-bob' || phase === 'settle';
const isFirstTagRenamed =
phase === 'rename-tag' ||
@@ -850,7 +857,7 @@ export function LiveDataVisual({
const addFilterLeft = isAddFilterDocked
? (addFilterLefts?.docked ?? DEFAULT_ADD_FILTER_LEFTS.docked)
: (addFilterLefts?.parked ?? DEFAULT_ADD_FILTER_LEFTS.parked);
- const viewCount = hasOpensFilterBeenRemoved ? 11 : 9;
+ const viewCount = hasEmployeesFilterBeenRemoved ? 11 : 9;
return (
@@ -888,7 +895,10 @@ export function LiveDataVisual({
$bottom={bobCursor.bottom}
$right={bobCursor.right}
>
-
+
- All contacts
+ All
{viewCount}
@@ -930,19 +940,19 @@ export function LiveDataVisual({
-
+
-
- Company
+ Type
- is Resend
+ is Customer
-
- Opens
+ Employees
- {'>5'}
+ {'>500'}
diff --git a/packages/twenty-website-new/src/sections/TrustedBy/components/ClientCount/ClientCount.tsx b/packages/twenty-website-new/src/sections/TrustedBy/components/ClientCount/ClientCount.tsx
index 961a2491497..21e1ed15f79 100644
--- a/packages/twenty-website-new/src/sections/TrustedBy/components/ClientCount/ClientCount.tsx
+++ b/packages/twenty-website-new/src/sections/TrustedBy/components/ClientCount/ClientCount.tsx
@@ -2,47 +2,27 @@ import { theme } from '@/theme';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
-const StyledBlock = styled.div`
- display: flex;
+const StyledChip = styled.div`
align-items: center;
- gap: ${theme.spacing(2)};
- height: 48px;
- padding: ${theme.spacing(1)} ${theme.spacing(3.75)} ${theme.spacing(1)}
- ${theme.spacing(1)};
- position: relative;
- overflow: clip;
+ display: inline-flex;
flex-shrink: 0;
- width: 157px;
+ gap: ${theme.spacing(2)};
+
+ img {
+ filter: grayscale(1);
+ opacity: 0.72;
+ }
`;
-const ShapeWrapper = styled.div`
- position: absolute;
- inset: 0;
- z-index: 0;
-`;
-
-const IconWrapper = styled.div`
- display: flex;
- align-items: center;
- justify-content: center;
- width: 40px;
- height: 40px;
- background-color: ${theme.colors.primary.text[5]};
- border-radius: 2px;
- position: relative;
- overflow: clip;
- z-index: 1;
-`;
-
-const StyledText = styled.p`
- font-family: ${theme.font.family.sans};
+const StyledText = styled.span`
+ color: ${theme.colors.primary.text[80]};
+ font-family: ${theme.font.family.mono};
+ font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
- font-size: ${theme.font.size(4)};
- line-height: ${theme.lineHeight(5.5)};
- color: ${theme.colors.primary.text[100]};
+ letter-spacing: 0.02em;
+ line-height: 1;
+ text-transform: uppercase;
white-space: nowrap;
- position: relative;
- z-index: 1;
`;
type ClientCountProps = {
@@ -51,27 +31,15 @@ type ClientCountProps = {
export function ClientCount({ label }: ClientCountProps) {
return (
-
-
-
-
-
-
-
+
+
{label}
-
+
);
}
diff --git a/packages/twenty-website-new/src/sections/TrustedBy/components/Logo/Logo.tsx b/packages/twenty-website-new/src/sections/TrustedBy/components/Logo/Logo.tsx
index 72fcc883c51..a19684a21df 100644
--- a/packages/twenty-website-new/src/sections/TrustedBy/components/Logo/Logo.tsx
+++ b/packages/twenty-website-new/src/sections/TrustedBy/components/Logo/Logo.tsx
@@ -4,21 +4,29 @@ import { styled } from '@linaria/react';
import NextImage from 'next/image';
const StyledLogo = styled.div`
- height: 32px;
- overflow: clip;
+ height: 28px;
flex-shrink: 0;
+ overflow: clip;
position: relative;
- width: 64px;
+ width: 56px;
@media (min-width: ${theme.breakpoints.md}px) {
- height: 40px;
- width: 80px;
+ height: 32px;
+ width: 64px;
}
`;
type LogoProps = TrustedByLogosType;
-export function Logo({ fit = 'contain', src }: LogoProps) {
+const DEFAULT_GRAY_BRIGHTNESS = 1;
+const DEFAULT_GRAY_OPACITY = 0.72;
+
+export function Logo({
+ fit = 'contain',
+ grayBrightness = DEFAULT_GRAY_BRIGHTNESS,
+ grayOpacity = DEFAULT_GRAY_OPACITY,
+ src,
+}: LogoProps) {
return (
);
diff --git a/packages/twenty-website-new/src/sections/TrustedBy/components/Logos/Logos.tsx b/packages/twenty-website-new/src/sections/TrustedBy/components/Logos/Logos.tsx
index dfe34f0b643..6a71d1d0e12 100644
--- a/packages/twenty-website-new/src/sections/TrustedBy/components/Logos/Logos.tsx
+++ b/packages/twenty-website-new/src/sections/TrustedBy/components/Logos/Logos.tsx
@@ -1,54 +1,44 @@
-import {
- type TrustedByClientCountLabelType,
- type TrustedByLogosType,
-} from '@/sections/TrustedBy/types';
+import { type TrustedByLogosType } from '@/sections/TrustedBy/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
-import { ClientCount } from '../ClientCount/ClientCount';
import { Logo } from '../Logo/Logo';
-const StyledLogos = styled.div`
- align-items: center;
- display: flex;
- flex-direction: column;
- gap: ${theme.spacing(5)};
- justify-content: center;
- position: relative;
- width: 100%;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- flex-direction: row;
- gap: ${theme.spacing(14)};
- }
-`;
-
const LogoStrip = styled.div`
align-items: center;
display: grid;
- gap: ${theme.spacing(4)} ${theme.spacing(6)};
- grid-template-columns: repeat(2, minmax(0, max-content));
+ gap: ${theme.spacing(4)} ${theme.spacing(4)};
+ grid-template-columns: repeat(3, minmax(0, max-content));
+ justify-content: center;
justify-items: center;
+ & > :last-child:nth-child(3n + 1) {
+ grid-column: 2;
+ }
+
@media (min-width: ${theme.breakpoints.md}px) {
display: flex;
- gap: ${theme.spacing(14)};
+ flex-wrap: wrap;
+ gap: ${theme.spacing(8)};
+ justify-content: center;
}
`;
type LogosProps = {
- clientCountLabel: TrustedByClientCountLabelType;
logos: TrustedByLogosType[];
};
-export function Logos({ clientCountLabel, logos }: LogosProps) {
+export function Logos({ logos }: LogosProps) {
return (
-
-
- {logos.map((logo, index) => (
-
- ))}
-
-
-
+
+ {logos.map((logo, index) => (
+
+ ))}
+
);
}
diff --git a/packages/twenty-website-new/src/sections/TrustedBy/components/Root/Root.tsx b/packages/twenty-website-new/src/sections/TrustedBy/components/Root/Root.tsx
index d0badd67bd8..43e881734d5 100644
--- a/packages/twenty-website-new/src/sections/TrustedBy/components/Root/Root.tsx
+++ b/packages/twenty-website-new/src/sections/TrustedBy/components/Root/Root.tsx
@@ -1,37 +1,144 @@
import { Container } from '@/design-system/components';
+import { PlusIcon } from '@/icons';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
-import type { ReactNode } from 'react';
+import { Children, type ReactNode } from 'react';
+
+const CORNER_OFFSET = '-6px';
const StyledSection = styled.section`
- width: 100%;
-`;
-
-const StyledContainer = styled(Container)`
- display: flex;
- flex-direction: column;
- gap: ${theme.spacing(6)};
padding-bottom: ${theme.spacing(12)};
- padding-left: ${theme.spacing(4)};
- padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(12)};
+ position: relative;
+ width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
padding-bottom: ${theme.spacing(16)};
- padding-left: ${theme.spacing(10)};
- padding-right: ${theme.spacing(10)};
padding-top: ${theme.spacing(16)};
}
`;
+const StyledContainer = styled(Container)`
+ padding-left: ${theme.spacing(4)};
+ padding-right: ${theme.spacing(4)};
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ padding-left: ${theme.spacing(10)};
+ padding-right: ${theme.spacing(10)};
+ }
+`;
+
+const StyledCard = styled.div`
+ align-items: center;
+ background-color: ${theme.colors.primary.background[100]};
+ border: 1px solid ${theme.colors.primary.border[10]};
+ border-radius: ${theme.radius(2)};
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(5)};
+ padding: ${theme.spacing(5)} ${theme.spacing(6)};
+ position: relative;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ align-items: stretch;
+ flex-direction: row;
+ gap: 0;
+ padding: 0 ${theme.spacing(8)};
+ }
+`;
+
+const StyledCell = styled.div`
+ align-items: center;
+ display: flex;
+ justify-content: center;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ padding: ${theme.spacing(5)} 0;
+
+ & + & {
+ border-left: 1px solid ${theme.colors.primary.border[10]};
+ }
+ }
+`;
+
+const StyledLabelCell = styled(StyledCell)`
+ @media (min-width: ${theme.breakpoints.md}px) {
+ padding-right: ${theme.spacing(6)};
+ }
+`;
+
+const StyledLogosCell = styled(StyledCell)`
+ flex: 1 1 auto;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ padding-left: ${theme.spacing(6)};
+ padding-right: ${theme.spacing(6)};
+ }
+`;
+
+const StyledCountCell = styled(StyledCell)`
+ @media (min-width: ${theme.breakpoints.md}px) {
+ padding-left: ${theme.spacing(6)};
+ }
+`;
+
+const CornerMarker = styled.span`
+ align-items: center;
+ display: flex;
+ height: 12px;
+ justify-content: center;
+ pointer-events: none;
+ position: absolute;
+ width: 12px;
+`;
+
+const CornerTopLeft = styled(CornerMarker)`
+ left: ${CORNER_OFFSET};
+ top: ${CORNER_OFFSET};
+`;
+
+const CornerTopRight = styled(CornerMarker)`
+ right: ${CORNER_OFFSET};
+ top: ${CORNER_OFFSET};
+`;
+
+const CornerBottomLeft = styled(CornerMarker)`
+ bottom: ${CORNER_OFFSET};
+ left: ${CORNER_OFFSET};
+`;
+
+const CornerBottomRight = styled(CornerMarker)`
+ bottom: ${CORNER_OFFSET};
+ right: ${CORNER_OFFSET};
+`;
+
interface RootProps {
children: ReactNode;
}
export function Root({ children }: RootProps) {
+ const [label, logos, count] = Children.toArray(children);
return (
- {children}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {label}
+ {logos}
+ {count}
+
+
);
}
diff --git a/packages/twenty-website-new/src/sections/TrustedBy/components/Separator/Separator.tsx b/packages/twenty-website-new/src/sections/TrustedBy/components/Separator/Separator.tsx
index d1c0a6ff1fb..b0f29d75804 100644
--- a/packages/twenty-website-new/src/sections/TrustedBy/components/Separator/Separator.tsx
+++ b/packages/twenty-website-new/src/sections/TrustedBy/components/Separator/Separator.tsx
@@ -1,29 +1,11 @@
-import { PlusIcon } from '@/icons';
import { TrustedBySeparatorType } from '@/sections/TrustedBy/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const StyledSeparatorRow = styled.div`
display: flex;
+ justify-content: center;
width: 100%;
- align-items: center;
- gap: ${theme.spacing(1.5)};
-`;
-
-const SeparatorIcon = styled.span`
- display: flex;
- align-items: center;
- width: 12px;
- height: 12px;
- flex-shrink: 0;
-`;
-
-const SeparatorLine = styled.div`
- flex-grow: 1;
- flex-shrink: 0;
- flex-basis: 0;
- height: 1px;
- background-color: ${theme.colors.primary.border[10]};
`;
const SeparatorText = styled.p`
@@ -42,15 +24,7 @@ type SeparatorProps = { separator: TrustedBySeparatorType };
export function Separator({ separator }: SeparatorProps) {
return (
-
-
-
-
{separator.text}
-
-
-
-
);
}
diff --git a/packages/twenty-website-new/src/sections/TrustedBy/types/TrustedByLogos.ts b/packages/twenty-website-new/src/sections/TrustedBy/types/TrustedByLogos.ts
index 1654f0c21f0..8aa521e1561 100644
--- a/packages/twenty-website-new/src/sections/TrustedBy/types/TrustedByLogos.ts
+++ b/packages/twenty-website-new/src/sections/TrustedBy/types/TrustedByLogos.ts
@@ -1,4 +1,6 @@
export type TrustedByLogosType = {
fit?: 'contain' | 'cover';
+ grayBrightness?: number;
+ grayOpacity?: number;
src: string;
};