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 ( + + {/* Dark rounded tile background */} + + + {/* Isometric cube — 3 shaded faces meeting at the center */} + {/* Top face (lightest) */} + + {/* Left face (mid) */} + + {/* Right face (darkest) */} + + + {/* White folded arrow — triangular silhouette with an inner fold line + that splits it into two slightly different shades */} + {/* Top wing (brighter) */} + + {/* Front wing (dimmer) */} + + + ); +}; 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} - - - - - - - - - - - - - - - {visual.favoritesNav && visual.favoritesNav.length > 0 ? ( - - Favorites - {visual.favoritesNav.map((item) => ( - + + + + + + + + + {visual.workspace.name} + + + + + + + + + + + + + + + {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 ? ( - - + {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 (