and a few fixes

---------

Co-authored-by: Abdullah. <125115953+mabdullahabaid@users.noreply.github.com>
This commit is contained in:
Thomas des Francs 2026-04-19 11:01:30 +02:00 committed by GitHub
parent 1f3defa7b3
commit 066003cb04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 10249 additions and 572 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,008 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View file

@ -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.',

View file

@ -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.',
},

View file

@ -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 (
<>
<Menu.Root
backgroundColor={theme.colors.primary.background[100]}
backgroundColor={theme.colors.secondary.background[5]}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
@ -84,31 +104,38 @@ export default async function HomePage() {
<Menu.Cta scheme="primary" />
</Menu.Root>
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
<Hero.Heading page={Pages.Home} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Home} body={HERO_DATA.body} size="sm" />
<Hero.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
<TalkToUsButton
color="secondary"
label="Talk to us"
variant="outlined"
/>
</Hero.Cta>
<Hero.Root
backgroundColor={theme.colors.secondary.background[5]}
showHomeBackground
>
<HeroIntroGroup data-halftone-exclude>
<HeroHeadingGroup>
<Hero.Heading page={Pages.Home} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Home} body={HERO_DATA.body} size="sm" />
</HeroHeadingGroup>
<Hero.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
<TalkToUsButton
color="secondary"
label="Talk to us"
variant="outlined"
/>
</Hero.Cta>
</HeroIntroGroup>
<Hero.HomeVisual visual={HERO_DATA.visual} />
</Hero.Root>
<TrustedBy.Root>
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
<TrustedBy.Logos
clientCountLabel={TRUSTED_BY_DATA.clientCountLabel}
logos={TRUSTED_BY_DATA.logos}
<TrustedBy.Logos logos={TRUSTED_BY_DATA.logos} />
<TrustedBy.ClientCount
label={TRUSTED_BY_DATA.clientCountLabel.text}
/>
</TrustedBy.Root>

View file

@ -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' },
};

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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<ButtonSize, number> = {
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 (
<>
<ButtonShape
dataSlot="button-base-shape"
fillColor={fillColor}
height={height}
strokeColor={strokeColor}
/>
<HoverFill data-slot="button-hover-fill" style={{ opacity: hoverFillOpacity }}>
<ButtonShape fillColor={hoverFillColor} strokeColor="none" />
<ButtonShape fillColor={hoverFillColor} height={height} strokeColor="none" />
</HoverFill>
<Label data-slot="button-label">{label}</Label>
</>

View file

@ -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 (
<ShapeContainer data-slot={dataSlot}>
<LeftCap width="4" height="40" viewBox="0 0 4 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<ShapeContainer data-slot={dataSlot} style={{ height }}>
<LeftCap
fill="none"
height={height}
viewBox={`0 0 4 ${height}`}
width="4"
xmlns="http://www.w3.org/2000/svg"
>
{isOutline ? (
<path d={LEFT_OUTLINE} fill={fillColor} stroke={strokeColor} strokeWidth="1" strokeLinejoin="round" strokeLinecap="round" />
<path
d={getLeftOutlinePath(height)}
fill={fillColor}
stroke={strokeColor}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
/>
) : (
<path d={LEFT_FILL} fill={fillColor} stroke={strokeColor} />
<path
d={getLeftFillPath(height)}
fill={fillColor}
stroke={strokeColor}
/>
)}
</LeftCap>
<MiddleSegment width="100%" height="40" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
<MiddleSegment
fill="none"
height={height}
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
width="100%"
>
{isOutline ? (
<>
<line x1="0" y1="0.5" x2="100%" y2="0.5" stroke={strokeColor} strokeWidth="1" />
<line x1="0" y1="39.5" x2="100%" y2="39.5" stroke={strokeColor} strokeWidth="1" />
<line
stroke={strokeColor}
strokeWidth="1"
x1="0"
x2="100%"
y1="0.5"
y2="0.5"
/>
<line
stroke={strokeColor}
strokeWidth="1"
x1="0"
x2="100%"
y1={height - 0.5}
y2={height - 0.5}
/>
</>
) : (
<rect width="100%" height="40" fill={fillColor} stroke={strokeColor} />
<rect
fill={fillColor}
height={height}
stroke={strokeColor}
width="100%"
/>
)}
</MiddleSegment>
<RightCap width="15" height="40" viewBox="0 0 15 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<RightCap
fill="none"
height={height}
viewBox={`0 0 15 ${height}`}
width="15"
xmlns="http://www.w3.org/2000/svg"
>
{isOutline ? (
<path d={RIGHT_OUTLINE} fill={fillColor} stroke={strokeColor} strokeWidth="1" strokeLinejoin="round" strokeLinecap="round" />
<path
d={getRightOutlinePath(height)}
fill={fillColor}
stroke={strokeColor}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
/>
) : (
<path d={RIGHT_FILL} fill={fillColor} stroke={strokeColor} />
<path
d={getRightFillPath(height)}
fill={fillColor}
stroke={strokeColor}
/>
)}
</RightCap>
</ShapeContainer>

View file

@ -24,15 +24,19 @@ export function LinkButton({
color,
href,
label,
size = 'regular',
type,
variant,
}: LinkButtonProps) {
const inner = <BaseButton color={color} label={label} variant={variant} />;
const inner = (
<BaseButton color={color} label={label} size={size} variant={variant} />
);
if (type === 'anchor') {
return (
<StyledButtonAnchor
data-color={color}
data-size={size}
data-variant={variant}
href={href}
rel="noopener noreferrer"
@ -44,7 +48,12 @@ export function LinkButton({
}
return (
<StyledButtonLink data-color={color} data-variant={variant} href={href}>
<StyledButtonLink
data-color={color}
data-size={size}
data-variant={variant}
href={href}
>
{inner}
</StyledButtonLink>
);

View file

@ -17,16 +17,18 @@ export function SubmitButton({
color,
label,
onClick,
size = 'regular',
variant,
}: SubmitButtonProps) {
return (
<StyledSubmitButton
data-color={color}
data-size={size}
data-variant={variant}
type="submit"
onClick={onClick}
type="submit"
>
<BaseButton color={color} label={label} variant={variant} />
<BaseButton color={color} label={label} size={size} variant={variant} />
</StyledSubmitButton>
);
}

View file

@ -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;

View file

@ -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<HomeBackgroundBreakpoint, 'desktop'>,
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 <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
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<HTMLImageElement> {
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<HTMLDivElement>(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 <StyledMount aria-hidden ref={mountReference} $isReady={isReady} />;
}

View file

@ -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 (

View file

@ -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,

View file

@ -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;
`;

View file

@ -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<ResizeHandle> = new Set([
'top-left',
'top-right',
'bottom-left',
'bottom-right',
'left',
'right',
]);
const VERTICAL_HANDLES: ReadonlySet<ResizeHandle> = new Set([
'top-left',
'top-right',
'bottom-left',
'bottom-right',
'top',
'bottom',
]);
const LEFT_HANDLES: ReadonlySet<ResizeHandle> = new Set([
'top-left',
'bottom-left',
'left',
]);
const TOP_HANDLES: ReadonlySet<ResizeHandle> = 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<HTMLDivElement>(null);
const dragStateRef = useRef<DragState | null>(null);
const resizeStateRef = useRef<ResizeState | null>(null);
const [position, setPosition] = useState<Position | null>(null);
const [size, setSize] = useState<Size | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<Shell
$isActive={isReady && zIndex > 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,
}}
>
<ResizeEdgeTop onPointerDown={startResize('top')} />
<ResizeEdgeRight onPointerDown={startResize('right')} />
<ResizeEdgeBottom onPointerDown={startResize('bottom')} />
<ResizeEdgeLeft onPointerDown={startResize('left')} />
<ResizeCornerTopLeft
aria-hidden
onPointerDown={startResize('top-left')}
/>
<ResizeCornerTopRight
aria-hidden
onPointerDown={startResize('top-right')}
/>
<ResizeCornerBottomLeft
aria-hidden
onPointerDown={startResize('bottom-left')}
/>
<ResizeCornerBottomRight
aria-hidden
onPointerDown={startResize('bottom-right')}
/>
<MacWindowBar isDragging={isDragging} onDragStart={handleDragStart} />
<Content>{children}</Content>
</Shell>
);
};

View file

@ -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<HTMLDivElement>) => 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 (
<BarRoot $isDragging={isDragging} onPointerDown={onDragStart}>
<TerminalTrafficLights horizontalInset={0} />
<Title>{title}</Title>
<RightSpacer aria-hidden />
</BarRoot>
);
};

View file

@ -0,0 +1,17 @@
'use client';
import Image from 'next/image';
export const ClaudeLogo = ({ size = 14 }: { size?: number }) => {
return (
<Image
alt=""
aria-hidden
draggable={false}
height={size}
src="/images/shared/companies/logos/claude.png"
style={{ display: 'block' }}
width={size}
/>
);
};

View file

@ -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 (
<svg
aria-hidden
fill="none"
height={size}
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
>
{/* Dark rounded tile background */}
<rect x="0" y="0" width="24" height="24" rx="5" fill="#0b0b0b" />
{/* Isometric cube — 3 shaded faces meeting at the center */}
{/* Top face (lightest) */}
<path
d="M12 4.5 L19.5 8.5 L12 12.5 L4.5 8.5 Z"
fill="#5e5e5e"
/>
{/* Left face (mid) */}
<path
d="M4.5 8.5 L12 12.5 L12 20.5 L4.5 16.5 Z"
fill="#3d3d3d"
/>
{/* Right face (darkest) */}
<path
d="M19.5 8.5 L19.5 16.5 L12 20.5 L12 12.5 Z"
fill="#2a2a2a"
/>
{/* White folded arrow triangular silhouette with an inner fold line
that splits it into two slightly different shades */}
{/* Top wing (brighter) */}
<path d="M5.2 8.2 L17.2 8.2 L12.8 12.5 Z" fill="#ffffff" />
{/* Front wing (dimmer) */}
<path d="M17.2 8.2 L13.8 17 L12.8 12.5 Z" fill="#dcdcdc" />
</svg>
);
};

View file

@ -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<ResizeHandle> = new Set([
'top-left',
'top-right',
'bottom-left',
'bottom-right',
'left',
'right',
]);
const VERTICAL_HANDLES: ReadonlySet<ResizeHandle> = new Set([
'top-left',
'top-right',
'bottom-left',
'bottom-right',
'top',
'bottom',
]);
const LEFT_HANDLES: ReadonlySet<ResizeHandle> = new Set([
'top-left',
'bottom-left',
'left',
]);
const TOP_HANDLES: ReadonlySet<ResizeHandle> = 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<HTMLDivElement>(null);
const dragStateRef = useRef<DragState | null>(null);
const resizeStateRef = useRef<ResizeState | null>(null);
const hasAnnouncedChatFinishedRef = useRef(false);
const [position, setPosition] = useState<TerminalPosition | null>(null);
const [size, setSize] = useState<TerminalSize>({
width: TERMINAL_INITIAL_WIDTH,
height: TERMINAL_INITIAL_HEIGHT,
});
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [messages, setMessages] = useState<ConversationMessage[]>([]);
const [view, setView] = useState<TerminalToggleValue>('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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<Shell
$animationsEnabled={animationsEnabled}
$dark={view === 'editor'}
$isDragging={isDragging}
$isResizing={isResizing}
$isReady={position !== null}
onPointerDown={activate}
ref={shellRef}
style={{
height: `${size.height}px`,
transform: position
? `translate(${position.left}px, ${position.top}px)`
: 'translate(0, 0)',
width: `${size.width}px`,
zIndex,
}}
>
<ResizeEdgeTop onPointerDown={startResize('top')} />
<ResizeEdgeRight onPointerDown={startResize('right')} />
<ResizeEdgeBottom onPointerDown={startResize('bottom')} />
<ResizeEdgeLeft onPointerDown={startResize('left')} />
<ResizeCornerTopLeft
aria-hidden
onPointerDown={startResize('top-left')}
/>
<ResizeCornerTopRight
aria-hidden
onPointerDown={startResize('top-right')}
/>
<ResizeCornerBottomLeft
aria-hidden
onPointerDown={startResize('bottom-left')}
/>
<ResizeCornerBottomRight
aria-hidden
onPointerDown={startResize('bottom-right')}
/>
<TerminalTopBar
diffOpen={isDiffOpen}
diffVisible={isChatFinished}
isDragging={isDragging}
onDragStart={handleDragStart}
onToggleDiff={handleToggleDiff}
onViewChange={handleViewChange}
onZoomTripleClick={handleJumpToConversationEnd}
view={view}
/>
<Body>
<ViewLayer $row $visible={view === 'ai-chat'}>
<ChatColumn>
{hasStartedConversation ? (
<ConversationPanel
instantComplete={instantComplete}
messages={messages}
onUndo={handleResetConversation}
onObjectCreated={onObjectCreated}
onChatFinished={handleChatFinishedInternal}
/>
) : null}
<TerminalPromptBox
isChatFinished={isChatFinished}
onReset={handleResetConversation}
onSend={handleSendPrompt}
promptIsPlaceholder={hasStartedConversation}
promptText={
hasStartedConversation
? CLEARED_PROMPT_TEXT
: INITIAL_PROMPT_TEXT
}
sendDisabled={hasStartedConversation}
/>
</ChatColumn>
<DiffSlide $open={isChatFinished && isDiffOpen}>
<TerminalDiff />
</DiffSlide>
</ViewLayer>
<ViewLayer $visible={view === 'editor'}>
<TerminalEditor showGeneratedFiles={isChatFinished} />
</ViewLayer>
</Body>
</Shell>
);
};

View file

@ -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 (
<PillButton
$active={isActive}
aria-label={isActive ? 'Hide changes' : 'Show changes'}
aria-pressed={isActive}
onClick={onClick}
type="button"
>
<Added>+{added}</Added>
<Removed>-{removed}</Removed>
</PillButton>
);
};

View file

@ -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<DiffTokenKind, string> = {
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 <span key={index}>{token.value}</span>;
}
return (
<span key={index} style={{ color: TOKEN_COLOR[token.kind] }}>
{token.value}
</span>
);
};
const renderChunk = (chunk: DiffChunk, index: number) => {
if (chunk.kind === 'unmodified') {
return (
<UnmodRow key={`unmod-${index}`}>
<UnmodChip>
<IconArrowsVertical size={12} stroke={1.8} />
{chunk.count} unmodified lines
</UnmodChip>
</UnmodRow>
);
}
return (
<LineRow key={`line-${index}`} $change={chunk.change}>
{chunk.change ? <ChangeBar $change={chunk.change} /> : null}
<LineContent $indented={Boolean(chunk.change)}>
<LineNumber>{chunk.lineNumber}</LineNumber>
<LineText>{chunk.tokens.map(renderToken)}</LineText>
</LineContent>
</LineRow>
);
};
const renderFile = (file: DiffFile) => (
<div key={file.id}>
<FileHeader>
<FilePath>{file.path}</FilePath>
<DiffAdded>+{file.added}</DiffAdded>
<DiffRemoved>-{file.removed}</DiffRemoved>
</FileHeader>
<DiffStack>{file.chunks.map(renderChunk)}</DiffStack>
</div>
);
export const TerminalDiff = () => {
return <Root>{DIFF_FILES.map(renderFile)}</Root>;
};

View file

@ -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;

View file

@ -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<FileIconKind, string> = {
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<TokenKind, string> = {
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 <span key={index}>{token.value}</span>;
}
return (
<span key={index} style={{ color: TOKEN_COLOR[token.kind] }}>
{token.value}
</span>
);
};
const renderExplorerNode = (
node: ExplorerNode,
activeFileId: string,
onSelect: (fileId: string) => void,
) => {
if (node.kind === 'folder') {
return (
<FileRowStatic key={node.id} $depth={node.depth}>
<Caret aria-hidden>{node.expanded ? '▾' : '▸'}</Caret>
<FolderName>{node.name}</FolderName>
</FileRowStatic>
);
}
const isSelectable = Boolean(node.fileId);
const isActive = node.fileId !== undefined && node.fileId === activeFileId;
if (!isSelectable) {
return (
<FileRowStatic key={node.id} $active={isActive} $depth={node.depth}>
<FileIcon $color={FILE_ICON_COLOR[node.icon]}>
{node.iconLabel}
</FileIcon>
<FileName $active={isActive}>{node.name}</FileName>
</FileRowStatic>
);
}
return (
<FileRowButton
key={node.id}
$active={isActive}
$depth={node.depth}
onClick={() => onSelect(node.fileId as string)}
type="button"
>
<FileIcon $color={FILE_ICON_COLOR[node.icon]}>{node.iconLabel}</FileIcon>
<FileName $active={isActive}>{node.name}</FileName>
</FileRowButton>
);
};
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<string>(fallbackFileId);
const [openFileIds, setOpenFileIds] = useState<string[]>([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<HTMLSpanElement>, 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 (
<Root>
<Sidebar>
<ExplorerHeader>Explorer</ExplorerHeader>
<FileTree>
{visibleExplorerNodes.map((node) =>
renderExplorerNode(node, activeFileId, handleSelectFile),
)}
</FileTree>
</Sidebar>
<EditorShell>
<TabBar>
{openFiles.map((file) => {
const isActive = file.id === activeFileId;
return (
<Tab
key={file.id}
$active={isActive}
onClick={() => setActiveFileId(file.id)}
>
<TabFileIcon>TS</TabFileIcon>
<TabTitle $active={isActive}>{file.name}</TabTitle>
<TabClose
aria-hidden
onClick={(event) => handleCloseTab(event, file.id)}
>
<IconX size={12} stroke={1.8} />
</TabClose>
</Tab>
);
})}
</TabBar>
<CodeRegion>
<CodeStack>
{codeLines.map((line, index) => (
<CodeLineRow key={`${activeFile.id}-${index}`}>
<Gutter>{index + 1}</Gutter>
<CodeText>
{line.length === 0 ? ' ' : line.map(renderCodeToken)}
</CodeText>
</CodeLineRow>
))}
</CodeStack>
</CodeRegion>
</EditorShell>
</Root>
);
};

View file

@ -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;

View file

@ -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<number | null>(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 (
<PromptArea>
<PromptBox data-wiggle={isWiggling ? 'true' : 'false'}>
<PromptTextRow
$clickable={isChatFinished}
onClick={handleEasterEggClick}
>
<PromptText
key={showEasterEgg ? `egg-${easterEggIndex}` : 'base'}
$isPlaceholder={promptIsPlaceholder}
>
{displayText}
</PromptText>
</PromptTextRow>
<PromptFooter>
<ChipRow>
<TerminalPromptChip
icon={<IconFolder size={13} stroke={1.8} />}
label="~/code/my-twenty-app"
/>
<TerminalPromptChip
icon={<IconGitBranch size={13} stroke={1.8} />}
label="main"
/>
</ChipRow>
<ActionRow>
<MythosButton type="button">Mythos</MythosButton>
<TerminalSendButton
disabled={isChatFinished ? false : sendDisabled}
mode={isChatFinished ? 'reset' : 'send'}
onClick={isChatFinished ? onReset : onSend}
/>
</ActionRow>
</PromptFooter>
</PromptBox>
</PromptArea>
);
};

View file

@ -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 (
<ChipRoot type="button">
<ChipIcon aria-hidden>{icon}</ChipIcon>
<ChipLabel>{label}</ChipLabel>
</ChipRoot>
);
};

View file

@ -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 }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<g filter="url(#finger_shadow)">
<path
d="M8.26999 16.2799C7.98999 15.9199 7.63999 15.1899 7.02999 14.2799C6.67999 13.7799 5.81999 12.8299 5.55999 12.3399C5.37257 12.0422 5.31818 11.6796 5.40999 11.3399C5.56695 10.6941 6.17956 10.2657 6.83999 10.3399C7.3508 10.4425 7.82022 10.6929 8.18999 11.0599C8.44817 11.3031 8.68566 11.5673 8.89999 11.8499C9.05999 12.0499 9.09999 12.1299 9.27999 12.3599C9.45999 12.5899 9.57999 12.8199 9.48999 12.4799C9.41999 11.9799 9.29999 11.1399 9.12999 10.3899C8.99999 9.81993 8.96999 9.72993 8.84999 9.29993C8.72999 8.86993 8.65999 8.50993 8.52999 8.01993C8.41116 7.5385 8.3177 7.05116 8.24999 6.55993C8.12395 5.93163 8.21565 5.27914 8.50999 4.70993C8.85939 4.3813 9.37192 4.29456 9.80999 4.48993C10.2506 4.81526 10.5791 5.26958 10.75 5.78993C11.0121 6.43032 11.187 7.10299 11.27 7.78993C11.43 8.78993 11.74 10.2499 11.75 10.5499C11.75 10.1799 11.68 9.39993 11.75 9.04993C11.8193 8.68505 12.073 8.38224 12.42 8.24993C12.7178 8.15855 13.0328 8.13801 13.34 8.18993C13.65 8.25474 13.9247 8.43307 14.11 8.68993C14.3417 9.27332 14.4703 9.89253 14.49 10.5199C14.5168 9.97051 14.6108 9.42646 14.77 8.89993C14.9371 8.66448 15.1811 8.49472 15.46 8.41993C15.7906 8.35948 16.1294 8.35948 16.46 8.41993C16.7311 8.51056 16.9682 8.68144 17.14 8.90993C17.3518 9.44027 17.48 10.0003 17.52 10.5699C17.52 10.7099 17.59 10.1799 17.81 9.82993C17.9243 9.49053 18.211 9.2379 18.5621 9.1672C18.9132 9.09651 19.2754 9.21849 19.5121 9.4872C19.7489 9.75591 19.8243 10.1305 19.71 10.4699C19.71 11.1199 19.71 11.0899 19.71 11.5299C19.71 11.9699 19.71 12.3599 19.71 12.7299C19.6736 13.3151 19.5933 13.8967 19.47 14.4699C19.296 14.977 19.0537 15.4581 18.75 15.8999C18.2644 16.4399 17.8632 17.0501 17.56 17.7099C17.4848 18.0377 17.4512 18.3737 17.46 18.7099C17.459 19.0206 17.4993 19.3299 17.58 19.6299C17.1711 19.6732 16.7589 19.6732 16.35 19.6299C15.96 19.5699 15.48 18.7899 15.35 18.5499C15.2857 18.4211 15.154 18.3396 15.01 18.3396C14.866 18.3396 14.7343 18.4211 14.67 18.5499C14.45 18.9299 13.96 19.6199 13.62 19.6599C12.95 19.7399 11.57 19.6599 10.48 19.6599C10.48 19.6599 10.66 18.6599 10.25 18.2999C9.83999 17.9399 9.41999 17.5199 9.10999 17.2399L8.26999 16.2799Z"
fill="white"
stroke="#202125"
strokeWidth="0.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.75 16.8259V13.3741C16.75 13.1675 16.5821 13 16.375 13C16.1679 13 16 13.1675 16 13.3741V16.8259C16 17.0325 16.1679 17.2 16.375 17.2C16.5821 17.2 16.75 17.0325 16.75 16.8259Z"
fill="#202125"
/>
<path
d="M14.77 16.8246L14.75 13.3711C14.7488 13.1649 14.5799 12.9988 14.3728 13C14.1657 13.0012 13.9988 13.1693 14 13.3754L14.02 16.8289C14.0212 17.035 14.1901 17.2012 14.3972 17.2C14.6043 17.1988 14.7712 17.0307 14.77 16.8246Z"
fill="#202125"
/>
<path
d="M12 13.379L12.02 16.8254C12.0212 17.0335 12.1901 17.2012 12.3972 17.2C12.6043 17.1988 12.7712 17.0291 12.77 16.821L12.75 13.3746C12.7488 13.1665 12.5799 12.9988 12.3728 13C12.1657 13.0012 11.9988 13.1709 12 13.379Z"
fill="#202125"
/>
</g>
<defs>
<filter
id="finger_shadow"
x="4.19133"
y="4.01172"
width="16.7461"
height="17.8588"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1" />
<feGaussianBlur stdDeviation="0.4" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow"
result="shape"
/>
</filter>
</defs>
</svg>
);
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<HTMLButtonElement>(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 (
<SendButtonWrapper>
<SendButtonRoot
$isReset={isReset}
aria-label={isReset ? 'Reset conversation' : 'Send message'}
disabled={disabled}
onClick={() => {
dismissHint();
onClick?.();
}}
onMouseEnter={dismissHint}
ref={buttonRef}
type="button"
>
{isReset ? (
<IconArrowBackUp size={16} stroke={2.2} />
) : (
<IconArrowUp size={16} stroke={2.2} />
)}
</SendButtonRoot>
{showHint && hintPos && typeof document !== 'undefined'
? createPortal(
<FingerHint
style={{
left: `${hintPos.left}px`,
top: `${hintPos.top}px`,
opacity: hintReady ? 1 : 0,
pointerEvents: 'none',
transform: `rotate(${FINGER_ROTATION}deg)`,
transformOrigin: 'center',
}}
>
<FingerTapAnim>
<FingerIcon size={FINGER_SIZE} />
</FingerTapAnim>
</FingerHint>,
document.body,
)
: null}
</SendButtonWrapper>
);
};

View file

@ -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<TerminalToggleValue>('ai-chat');
const value = controlledValue ?? internalValue;
const isDark = theme === 'dark';
const selectSegment = (nextValue: TerminalToggleValue) => () => {
if (controlledValue === undefined) {
setInternalValue(nextValue);
}
onChange?.(nextValue);
};
return (
<ToggleRoot $dark={isDark} role="tablist" aria-label="Terminal mode">
<SegmentButton
$active={value === 'editor'}
$dark={isDark}
aria-selected={value === 'editor'}
onClick={selectSegment('editor')}
role="tab"
type="button"
>
<SegmentIconWrap>
<CursorLogo size={14} />
</SegmentIconWrap>
Editor
</SegmentButton>
<SegmentButton
$active={value === 'ai-chat'}
$dark={isDark}
aria-selected={value === 'ai-chat'}
onClick={selectSegment('ai-chat')}
role="tab"
type="button"
>
<SegmentIconWrap>
<ClaudeLogo size={14} />
</SegmentIconWrap>
AI Chat
</SegmentButton>
</ToggleRoot>
);
};

View file

@ -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<HTMLDivElement>) => 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 (
<TopBarRoot
$dark={isDark}
$isDragging={isDragging}
onPointerDown={onDragStart}
>
<TerminalTrafficLights onZoomTripleClick={onZoomTripleClick} />
<TerminalToggle
onChange={onViewChange}
theme={isDark ? 'dark' : 'light'}
value={view}
/>
<TopRight $visible={diffVisible && !isDark}>
<DiffPill
added={DIFF_TOTALS.added}
isActive={diffOpen}
onClick={onToggleDiff}
removed={DIFF_TOTALS.removed}
/>
</TopRight>
</TopBarRoot>
);
};

View file

@ -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 = () => (
<svg aria-hidden width="6" height="6" viewBox="0 0 6 6">
<path
d="M1 1 L5 5 M5 1 L1 5"
stroke={TERMINAL_TOKENS.trafficLight.glyph}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
const MinimizeGlyph = () => (
<svg aria-hidden width="6" height="6" viewBox="0 0 6 6">
<path
d="M1 3 L5 3"
stroke={TERMINAL_TOKENS.trafficLight.glyph}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
const ZoomGlyph = () => (
<svg aria-hidden width="6" height="6" viewBox="0 0 6 6">
<path
d="M1.5 1.5 L1.5 4.5 L4.5 4.5 Z M4.5 1.5 L1.5 4.5"
fill={TERMINAL_TOKENS.trafficLight.glyph}
/>
</svg>
);
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<boolean[]>([
false,
false,
false,
]);
const [returnedDots, setReturnedDots] = useState<boolean[]>([
false,
false,
false,
]);
const [portalReady, setPortalReady] = useState(false);
const originalRefs = useRef<Array<HTMLButtonElement | null>>([
null,
null,
null,
]);
const flyingRefs = useRef<Array<HTMLButtonElement | null>>([
null,
null,
null,
]);
const physicsRef = useRef<PhysicsState[]>([]);
useEffect(() => {
setPortalReady(true);
}, []);
const handleZoomClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
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 (
<TrafficLightsContainer
$horizontalInset={horizontalInset}
aria-label="Window controls"
>
<TrafficLightDot
aria-label="Close"
$background={TERMINAL_TOKENS.trafficLight.close}
$backgroundActive={TERMINAL_TOKENS.trafficLight.closeActive}
data-escaping={isEscaping && !returnedDots[0] ? 'true' : 'false'}
ref={setOriginalRef(0)}
type="button"
>
<CloseGlyph />
</TrafficLightDot>
<TrafficLightDot
aria-label="Minimize"
$background={TERMINAL_TOKENS.trafficLight.minimize}
$backgroundActive={TERMINAL_TOKENS.trafficLight.minimizeActive}
data-escaping={isEscaping && !returnedDots[1] ? 'true' : 'false'}
ref={setOriginalRef(1)}
type="button"
>
<MinimizeGlyph />
</TrafficLightDot>
<TrafficLightDot
aria-label="Zoom"
$background={TERMINAL_TOKENS.trafficLight.zoom}
$backgroundActive={TERMINAL_TOKENS.trafficLight.zoomActive}
data-escaping={isEscaping && !returnedDots[2] ? 'true' : 'false'}
onClick={handleZoomClick}
ref={setOriginalRef(2)}
type="button"
>
<ZoomGlyph />
</TrafficLightDot>
{isEscaping && portalReady
? createPortal(
<>
{DOT_DEFINITIONS.map(
({ background, backgroundActive, Glyph }, index) =>
returnedDots[index] ? null : (
<FlyingDotContainer
key={index}
aria-label="Return traffic light"
onClick={() => handleCatchDot(index)}
ref={setFlyingRef(index)}
type="button"
>
<FlyingDotBall
$background={background}
$backgroundActive={backgroundActive}
data-returning={returningDots[index] ? 'true' : 'false'}
onAnimationEnd={() => handlePopAnimationEnd(index)}
>
<Glyph />
</FlyingDotBall>
</FlyingDotContainer>
),
)}
</>,
document.body,
)
: null}
</TrafficLightsContainer>
);
};

View file

@ -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: <span key={key}>{value}</span>,
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', <InlineCode>Companies</InlineCode>),
text(' object for customers, with shared UUIDs in '),
node('rocket-ids', <FileLink>schema-identifiers.ts</FileLink>),
text('. First up: '),
node(
'rocket-chip',
<InlineCode>Rocket</InlineCode>,
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', <FileLink>rocket.object.ts</FileLink>),
text('.'),
];
const buildLaunchParagraph = (
onObjectCreated?: (id: string) => void,
): StreamingSegment[] => [
text('Next up: '),
node(
'launch-chip',
<InlineCode>Launch</InlineCode>,
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', <FileLink>launch.object.ts</FileLink>),
text('.'),
];
const buildPayloadParagraph = (
onObjectCreated?: (id: string) => void,
): StreamingSegment[] => [
text('Now '),
node(
'payload-chip',
<InlineCode>Payload</InlineCode>,
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', <FileLink>payload.object.ts</FileLink>),
text('.'),
];
const buildCustomerParagraph = (
onObjectCreated?: (id: string) => void,
): StreamingSegment[] => [
text('For customers, no new object — I reuse the standard '),
node(
'customer-chip',
<InlineCode>Companies</InlineCode>,
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', <FileLink>payload.object.ts</FileLink>),
text(' points its '),
node('customer-field', <InlineCode>customer</InlineCode>),
text(' relation straight at it.'),
];
const buildLaunchSiteParagraph = (
onObjectCreated?: (id: string) => void,
): StreamingSegment[] => [
text('Last object: '),
node(
'launch-site-chip',
<InlineCode>Launch site</InlineCode>,
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', <FileLink>launch-site.object.ts</FileLink>),
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', <InlineCode>New</InlineCode>),
text(' — '),
node('pa-rocket', <InlineCode>Rocket</InlineCode>),
text(' has reuse / retire shortcuts, '),
node('pa-launch', <InlineCode>Launch</InlineCode>),
text(' has '),
node('pa-l-resched', <InlineCode>Reschedule</InlineCode>),
text(' and '),
node('pa-l-payload', <InlineCode>Add payload</InlineCode>),
text(', '),
node('pa-payload', <InlineCode>Payload</InlineCode>),
text(' has '),
node('pa-p-book', <InlineCode>Book slot</InlineCode>),
text(', '),
node('pa-companies', <InlineCode>Companies</InlineCode>),
text(' has a quick '),
node('pa-c-status', <InlineCode>Set status</InlineCode>),
text(', and '),
node('pa-site', <InlineCode>Launch site</InlineCode>),
text(' has '),
node('pa-s-window', <InlineCode>Book window</InlineCode>),
text('. Defined under '),
node('pa-folder', <FileLink>src/command-menu-items/</FileLink>),
text('.'),
];
const WRAPUP_PARAGRAPH: StreamingSegment[] = [
text('Relations wire '),
node('w-rl', <InlineCode>Rocket Launches</InlineCode>),
text(', '),
node('w-sl', <InlineCode>LaunchSite Launches</InlineCode>),
text(', '),
node('w-cp', <InlineCode>Company Payloads</InlineCode>),
text(', and '),
node('w-lp', <InlineCode>Launch Payloads</InlineCode>),
text('. Each object gets an index view and sidebar entry; '),
node('w-launches', <InlineCode>Launches</InlineCode>),
text(' also has '),
node('w-upcoming', <FileLink>upcoming-launches.view.ts</FileLink>),
text(' and '),
node('w-past', <FileLink>past-launches.view.ts</FileLink>),
text('. Verified with '),
node('w-lint', <InlineCode>yarn lint</InlineCode>),
text(', '),
node('w-tsc', <InlineCode>tsc --noEmit</InlineCode>),
text(', '),
node(
'w-vitest',
<InlineCode>vitest run schema.integration-test.ts</InlineCode>,
),
text(', and '),
node('w-dev', <InlineCode>yarn twenty dev --once</InlineCode>),
text('. Reference: '),
node(
'w-docs',
<ReferenceLink
href="https://twenty.com/developers"
onClick={(event) => event.preventDefault()}
>
Twenty app-building docs
</ReferenceLink>,
),
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<Stage>(
instantComplete ? 'done' : 'thinking',
);
const hasNotifiedChatFinishedRef = useRef(false);
const advanceTimeoutsRef = useRef<Set<number>>(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 (
<ResponseRoot>
{stage === 'thinking' && <ThinkingIndicator />}
{has('rocket') && (
<Paragraph>
<StreamingText
charDurationMs={CHAT_TIMINGS.textStreamCharMs}
instant={instantComplete}
onComplete={
stage === 'rocket'
? advanceTo('launch', AFTER_OBJECT_BEAT_MS)
: undefined
}
segments={rocketParagraph}
/>
</Paragraph>
)}
{has('launch') && (
<Paragraph>
<StreamingText
charDurationMs={CHAT_TIMINGS.textStreamCharMs}
instant={instantComplete}
onComplete={
stage === 'launch'
? advanceTo('payload', AFTER_OBJECT_BEAT_MS)
: undefined
}
segments={launchParagraph}
/>
</Paragraph>
)}
{has('payload') && (
<Paragraph>
<StreamingText
charDurationMs={CHAT_TIMINGS.textStreamCharMs}
instant={instantComplete}
onComplete={
stage === 'payload'
? advanceTo('customer', AFTER_OBJECT_BEAT_MS)
: undefined
}
segments={payloadParagraph}
/>
</Paragraph>
)}
{has('customer') && (
<Paragraph>
<StreamingText
charDurationMs={CHAT_TIMINGS.textStreamCharMs}
instant={instantComplete}
onComplete={
stage === 'customer'
? advanceTo('launchSite', AFTER_OBJECT_BEAT_MS)
: undefined
}
segments={customerParagraph}
/>
</Paragraph>
)}
{has('launchSite') && (
<Paragraph>
<StreamingText
charDurationMs={CHAT_TIMINGS.textStreamCharMs}
instant={instantComplete}
onComplete={
stage === 'launchSite'
? advanceTo('actions', BETWEEN_PARAGRAPHS_MS)
: undefined
}
segments={launchSiteParagraph}
/>
</Paragraph>
)}
{has('actions') && (
<Paragraph>
<StreamingText
charDurationMs={CHAT_TIMINGS.textStreamCharMs}
instant={instantComplete}
onComplete={
stage === 'actions'
? advanceTo('wrapup', BETWEEN_PARAGRAPHS_MS)
: undefined
}
segments={PINNED_ACTIONS_PARAGRAPH}
/>
</Paragraph>
)}
{has('wrapup') && (
<Paragraph>
<StreamingText
charDurationMs={CHAT_TIMINGS.textStreamCharMs}
instant={instantComplete}
onComplete={
stage === 'wrapup' ? advanceTo('card', BEFORE_CARD_MS) : undefined
}
segments={WRAPUP_PARAGRAPH}
/>
</Paragraph>
)}
{has('card') && (
<CardWrap $instant={instantComplete}>
<ChangesSummaryCard onUndo={onUndo} />
</CardWrap>
)}
</ResponseRoot>
);
};

View file

@ -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) => (
<DiffCounts>
{change.added > 0 ? (
<DiffAdded>+{change.added}</DiffAdded>
) : (
<ZeroCount>+0</ZeroCount>
)}
{change.removed > 0 ? (
<DiffRemoved>-{change.removed}</DiffRemoved>
) : (
<ZeroCount>-0</ZeroCount>
)}
</DiffCounts>
);
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 (
<CardRoot>
<Header>
<HeaderTitle>
<span>{ROCKET_CHANGESET.length} files changed</span>
<DiffAdded>+{CHANGESET_TOTALS.added}</DiffAdded>
<DiffRemoved>-{CHANGESET_TOTALS.removed}</DiffRemoved>
</HeaderTitle>
<UndoButton onClick={onUndo} type="button">
Undo
<IconArrowBackUp size={12} stroke={2} />
</UndoButton>
</Header>
<FileList>
{visibleChanges.map((change, index) => (
<FileRow
$delay={`${ROW_BASE_DELAY_MS + index * ROW_STAGGER_MS}ms`}
key={`${change.path}-${index}`}
>
<FilePath>{change.path}</FilePath>
{renderDiffCounts(change)}
<Chevron>
<IconChevronRight size={14} stroke={1.8} />
</Chevron>
</FileRow>
))}
</FileList>
{hiddenCount > 0 && (
<SeeMoreButton
onClick={() => setIsExpanded((current) => !current)}
type="button"
>
<SeeMoreChevron aria-hidden>
{isExpanded ? (
<IconChevronUp size={12} stroke={2} />
) : (
<IconChevronDown size={12} stroke={2} />
)}
</SeeMoreChevron>
{isExpanded ? 'See less' : `See ${hiddenCount} more`}
</SeeMoreButton>
)}
</CardRoot>
);
};

View file

@ -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<HTMLDivElement>(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 (
<PanelRoot ref={scrollRef}>
{messages.map((message) =>
message.role === 'user' ? (
<UserMessage
instant={instantComplete}
key={message.id}
text={message.text}
/>
) : (
<AssistantResponse
instantComplete={instantComplete}
key={message.id}
onUndo={onUndo}
onObjectCreated={onObjectCreated}
onChatFinished={onChatFinished}
/>
),
)}
</PanelRoot>
);
};

View file

@ -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<StreamingSegment>;
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(
<span key={`s-${index}`}>{segment.value.slice(0, take)}</span>,
);
remaining -= take;
} else {
const cost = segment.length ?? 1;
if (remaining < cost) {
break;
}
rendered.push(<span key={`s-${index}`}>{segment.value}</span>);
remaining -= cost;
}
}
const isComplete = revealed >= totalLength;
return (
<StreamWrap>
{rendered}
{!isComplete && <Caret />}
</StreamWrap>
);
};
export type { StreamingSegment };

View file

@ -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 (
<ThinkingRoot aria-label="Thinking">
<ThinkingDot $delay="0s" />
<ThinkingDot $delay="0.15s" />
<ThinkingDot $delay="0.3s" />
</ThinkingRoot>
);
};

View file

@ -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 (
<BubbleRow $instant={instant}>
<Bubble>{text}</Bubble>
</BubbleRow>
);
};

View file

@ -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;

View file

@ -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<FileChange> = [
{ 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 },
);

View file

@ -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;

View file

@ -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 (
<TableShell>
<GripRail aria-hidden="true">
@ -995,12 +983,13 @@ export function TablePage({
</EmptyFillCell>
</HeaderRow>
{page.rows.map((row) => {
{page.rows.map((row, rowIndex) => {
const hovered = hoveredRowId === row.id;
return (
<DataRow
key={row.id}
$rowIndex={rowIndex}
onMouseEnter={() => setHoveredRowId(row.id)}
onMouseLeave={() =>
setHoveredRowId((current) =>

View file

@ -0,0 +1,91 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
// Z-order manager for the hero's floating windows. Each window registers on
// mount and can call `activate()` to jump to the front on click. The provider
// keeps a stack (first = back, last = front); z-indexes start at 2 so floating
// windows always sit above the static scene.
//
// Split into two contexts on purpose:
// - `Api` — stable callbacks (never changes identity) so `useEffect` in the
// consumer only runs on mount / unmount.
// - `Stack` — the reactive ordering; changes every activate, consumers
// derive their z-index from it.
// Without this split, `useEffect(() => context.register(id), [context])`
// would unregister and re-register on every activate call (because the
// memoized value reference changes), causing stack churn.
type WindowOrderApi = {
register: (id: string) => void;
unregister: (id: string) => void;
activate: (id: string) => void;
};
const WindowOrderApiContext = createContext<WindowOrderApi | null>(null);
const WindowOrderStackContext = createContext<ReadonlyArray<string>>([]);
export const WindowOrderProvider = ({ children }: { children: ReactNode }) => {
const [stack, setStack] = useState<string[]>([]);
const api = useMemo<WindowOrderApi>(
() => ({
register: (id) =>
setStack((previous) =>
previous.includes(id) ? previous : [...previous, id],
),
unregister: (id) =>
setStack((previous) => previous.filter((item) => item !== id)),
activate: (id) =>
setStack((previous) => {
if (previous[previous.length - 1] === id) {
return previous;
}
return [...previous.filter((item) => item !== id), id];
}),
}),
[],
);
return (
<WindowOrderApiContext.Provider value={api}>
<WindowOrderStackContext.Provider value={stack}>
{children}
</WindowOrderStackContext.Provider>
</WindowOrderApiContext.Provider>
);
};
export const useWindowOrder = (id: string) => {
const api = useContext(WindowOrderApiContext);
const stack = useContext(WindowOrderStackContext);
useEffect(() => {
if (!api) {
return undefined;
}
api.register(id);
return () => {
api.unregister(id);
};
}, [api, id]);
const zIndex = useMemo(() => {
const index = stack.indexOf(id);
return index === -1 ? 1 : index + 2;
}, [stack, id]);
const activate = useCallback(() => {
api?.activate(id);
}, [api, id]);
return { activate, zIndex };
};

View file

@ -0,0 +1,55 @@
import type { HeroNavbarActionType } from '../../types/HeroHomeData';
// Each object registers its own pinned commands — these render to the left of
// the New Record button once the chat has created/revealed the object. The
// action shapes are intentionally identical to the real Twenty command-menu
// item model (label + icon) so the mock lines up with what the assistant
// announces in the chat paragraph. Kept in sync with the source files in
// editorData.ts under src/command-menu-items/.
type PinnedAction = HeroNavbarActionType;
// Max 3 commands per object — reserved for the highest-value ops so the
// header stays readable alongside the built-in New Record button.
const ROCKET_PINNED_ACTIONS: PinnedAction[] = [
// "Fly again" leans into reusable-rocket ops: re-fly this vehicle on a new
// mission. Paired with launch scheduling and retirement to cover a rocket's
// full lifecycle (reuse / schedule / retire).
{ icon: 'repeat', label: 'Fly again' },
{ icon: 'calendarPlus', label: 'Schedule launch' },
{ icon: 'playerPause', label: 'Retire' },
];
const LAUNCH_PINNED_ACTIONS: PinnedAction[] = [
{ icon: 'calendarClock', label: 'Reschedule' },
{ icon: 'box', label: 'Add payload' },
{ icon: 'calendarEvent', label: 'Upcoming' },
];
const PAYLOAD_PINNED_ACTIONS: PinnedAction[] = [
// Action-oriented verb + space-ops framing: customers book launch slots.
// Scopes the quick-create to a customer-linked payload without leaning on
// "New" (which collides with the default New button).
{ icon: 'calendarPlus', label: 'Book slot' },
{ icon: 'flag', label: 'Set status' },
];
const COMPANIES_PINNED_ACTIONS: PinnedAction[] = [
{ icon: 'flag', label: 'Set status' },
];
const LAUNCH_SITE_PINNED_ACTIONS: PinnedAction[] = [
// Status transitions (Active / Standby / Maintenance) are the most common
// site-level op; the other two are high-value jumps into related data.
{ icon: 'flag', label: 'Set status' },
{ icon: 'calendarPlus', label: 'Book window' },
{ icon: 'rocket', label: 'Launches' },
];
export const OBJECT_PINNED_ACTIONS: Record<string, PinnedAction[]> = {
rockets: ROCKET_PINNED_ACTIONS,
launches: LAUNCH_PINNED_ACTIONS,
payloads: PAYLOAD_PINNED_ACTIONS,
companies: COMPANIES_PINNED_ACTIONS,
'launch-sites': LAUNCH_SITE_PINNED_ACTIONS,
};

View file

@ -0,0 +1,576 @@
import type {
HeroSidebarItem,
HeroTablePageDefinition,
} from '../../types/HeroHomeData';
export const ROCKET_ITEM_ID = 'rockets';
export const ROCKET_ITEM_LABEL = 'Rockets';
const ROCKET_PAGE: HeroTablePageDefinition = {
type: 'table',
header: {
title: 'All Rockets',
count: 6,
},
columns: [
{ id: 'name', label: 'Name', width: 200, isFirstColumn: true },
{ id: 'serialNumber', label: 'Serial Number', width: 130 },
{ id: 'manufacturer', label: 'Manufacturer', width: 180 },
{ id: 'status', label: 'Status', width: 130 },
{ id: 'reusable', label: 'Reusable', width: 110 },
{ id: 'launchDate', label: 'Launch Date', width: 140 },
{ id: 'targetOrbit', label: 'Target Orbit', width: 140 },
{ id: 'heightMeters', label: 'Height (m)', width: 120, align: 'right' },
{ id: 'massKg', label: 'Mass (kg)', width: 130, align: 'right' },
],
rows: [
{
id: 'falcon-9',
cells: {
name: {
type: 'text',
value: 'Falcon 9',
shortLabel: 'F',
tone: 'blue',
},
serialNumber: { type: 'text', value: 'B1062' },
manufacturer: { type: 'entity', name: 'SpaceX', domain: 'spacex.com' },
status: { type: 'tag', value: 'Active' },
reusable: { type: 'boolean', value: true },
launchDate: { type: 'text', value: 'Apr 11, 2024' },
targetOrbit: { type: 'text', value: 'LEO' },
heightMeters: { type: 'number', value: '70' },
massKg: { type: 'number', value: '549,054' },
},
},
{
id: 'starship',
cells: {
name: {
type: 'text',
value: 'Starship',
shortLabel: 'S',
tone: 'amber',
},
serialNumber: { type: 'text', value: 'S29' },
manufacturer: { type: 'entity', name: 'SpaceX', domain: 'spacex.com' },
status: { type: 'tag', value: 'Testing' },
reusable: { type: 'boolean', value: true },
launchDate: { type: 'text', value: 'Jun 6, 2024' },
targetOrbit: { type: 'text', value: 'Mars Transfer' },
heightMeters: { type: 'number', value: '120' },
massKg: { type: 'number', value: '5,000,000' },
},
},
{
id: 'new-glenn',
cells: {
name: {
type: 'text',
value: 'New Glenn',
shortLabel: 'N',
tone: 'teal',
},
serialNumber: { type: 'text', value: 'NG-1' },
manufacturer: {
type: 'entity',
name: 'Blue Origin',
domain: 'blueorigin.com',
},
status: { type: 'tag', value: 'Active' },
reusable: { type: 'boolean', value: true },
launchDate: { type: 'text', value: 'Jan 16, 2025' },
targetOrbit: { type: 'text', value: 'GTO' },
heightMeters: { type: 'number', value: '98' },
massKg: { type: 'number', value: '1,400,000' },
},
},
{
id: 'electron',
cells: {
name: {
type: 'text',
value: 'Electron',
shortLabel: 'E',
tone: 'purple',
},
serialNumber: { type: 'text', value: 'F52' },
manufacturer: {
type: 'entity',
name: 'Rocket Lab',
domain: 'rocketlabusa.com',
},
status: { type: 'tag', value: 'Active' },
reusable: { type: 'boolean', value: false },
launchDate: { type: 'text', value: 'Sep 20, 2024' },
targetOrbit: { type: 'text', value: 'SSO' },
heightMeters: { type: 'number', value: '18' },
massKg: { type: 'number', value: '13,000' },
},
},
{
id: 'ariane-6',
cells: {
name: {
type: 'text',
value: 'Ariane 6',
shortLabel: 'A',
tone: 'pink',
},
serialNumber: { type: 'text', value: 'VA262' },
manufacturer: {
type: 'entity',
name: 'Arianespace',
domain: 'arianespace.com',
},
status: { type: 'tag', value: 'Active' },
reusable: { type: 'boolean', value: false },
launchDate: { type: 'text', value: 'Jul 9, 2024' },
targetOrbit: { type: 'text', value: 'GTO' },
heightMeters: { type: 'number', value: '63' },
massKg: { type: 'number', value: '860,000' },
},
},
{
id: 'vulcan-centaur',
cells: {
name: {
type: 'text',
value: 'Vulcan Centaur',
shortLabel: 'V',
tone: 'green',
},
serialNumber: { type: 'text', value: 'VC-002' },
manufacturer: {
type: 'entity',
name: 'ULA',
domain: 'ulalaunch.com',
},
status: { type: 'tag', value: 'Active' },
reusable: { type: 'boolean', value: false },
launchDate: { type: 'text', value: 'Oct 4, 2024' },
targetOrbit: { type: 'text', value: 'GEO' },
heightMeters: { type: 'number', value: '62' },
massKg: { type: 'number', value: '546,700' },
},
},
],
};
export const ROCKET_SIDEBAR_ITEM: HeroSidebarItem = {
id: ROCKET_ITEM_ID,
label: ROCKET_ITEM_LABEL,
icon: { kind: 'tabler', name: 'rocket', tone: 'violet' },
page: ROCKET_PAGE,
};
// -- Additional CRM objects --
// Each object below gets its own empty-but-structured index view so the
// workspace can reflect the schema as it's scaffolded by the chat.
const LAUNCH_PAGE: HeroTablePageDefinition = {
type: 'table',
header: { title: 'Launches', count: 5 },
columns: [
{ id: 'name', label: 'Name', width: 200, isFirstColumn: true },
{ id: 'missionCode', label: 'Mission Code', width: 140 },
{ id: 'status', label: 'Status', width: 130 },
{ id: 'missionType', label: 'Mission Type', width: 140 },
{ id: 'plannedLaunchAt', label: 'Planned Launch', width: 170 },
{ id: 'rocket', label: 'Rocket', width: 170 },
{ id: 'launchSite', label: 'Launch Site', width: 170 },
{ id: 'actualLaunchAt', label: 'Actual Launch', width: 170 },
],
rows: [
{
id: 'crs-29',
cells: {
name: { type: 'text', value: 'CRS-29', shortLabel: 'C', tone: 'blue' },
missionCode: { type: 'text', value: 'NASA-CRS-29' },
status: { type: 'tag', value: 'Success' },
missionType: { type: 'tag', value: 'Cargo' },
plannedLaunchAt: { type: 'text', value: 'Nov 9, 2023' },
rocket: {
type: 'relation',
items: [{ name: 'Falcon 9', shortLabel: 'F', tone: 'blue' }],
},
launchSite: {
type: 'relation',
items: [{ name: 'LC-39A', shortLabel: 'K', tone: 'red' }],
},
actualLaunchAt: { type: 'text', value: 'Nov 9, 2023' },
},
},
{
id: 'artemis-ii',
cells: {
name: {
type: 'text',
value: 'Artemis II',
shortLabel: 'A',
tone: 'amber',
},
missionCode: { type: 'text', value: 'NASA-ART-2' },
status: { type: 'tag', value: 'Scheduled' },
missionType: { type: 'tag', value: 'Crewed' },
plannedLaunchAt: { type: 'text', value: 'Sep 26, 2025' },
rocket: {
type: 'relation',
items: [{ name: 'SLS Block 1', shortLabel: 'S', tone: 'purple' }],
},
launchSite: {
type: 'relation',
items: [{ name: 'LC-39B', shortLabel: 'K', tone: 'red' }],
},
actualLaunchAt: { type: 'text', value: '—' },
},
},
{
id: 'ift-5',
cells: {
name: {
type: 'text',
value: 'Starship IFT-5',
shortLabel: 'S',
tone: 'amber',
},
missionCode: { type: 'text', value: 'SPX-IFT-5' },
status: { type: 'tag', value: 'Success' },
missionType: { type: 'tag', value: 'Test' },
plannedLaunchAt: { type: 'text', value: 'Oct 13, 2024' },
rocket: {
type: 'relation',
items: [{ name: 'Starship', shortLabel: 'S', tone: 'amber' }],
},
launchSite: {
type: 'relation',
items: [{ name: 'Starbase', shortLabel: 'S', tone: 'orange' }],
},
actualLaunchAt: { type: 'text', value: 'Oct 13, 2024' },
},
},
{
id: 'euclid-launch',
cells: {
name: { type: 'text', value: 'Euclid', shortLabel: 'E', tone: 'teal' },
missionCode: { type: 'text', value: 'ESA-EUC-1' },
status: { type: 'tag', value: 'Success' },
missionType: { type: 'tag', value: 'Commercial' },
plannedLaunchAt: { type: 'text', value: 'Jul 1, 2023' },
rocket: {
type: 'relation',
items: [{ name: 'Falcon 9', shortLabel: 'F', tone: 'blue' }],
},
launchSite: {
type: 'relation',
items: [{ name: 'SLC-40', shortLabel: 'C', tone: 'red' }],
},
actualLaunchAt: { type: 'text', value: 'Jul 1, 2023' },
},
},
{
id: 'psyche-launch',
cells: {
name: {
type: 'text',
value: 'Psyche',
shortLabel: 'P',
tone: 'purple',
},
missionCode: { type: 'text', value: 'NASA-PSY-1' },
status: { type: 'tag', value: 'Success' },
missionType: { type: 'tag', value: 'Commercial' },
plannedLaunchAt: { type: 'text', value: 'Oct 13, 2023' },
rocket: {
type: 'relation',
items: [
{ name: 'Falcon Heavy', shortLabel: 'F', tone: 'blue' },
],
},
launchSite: {
type: 'relation',
items: [{ name: 'LC-39A', shortLabel: 'K', tone: 'red' }],
},
actualLaunchAt: { type: 'text', value: 'Oct 13, 2023' },
},
},
],
};
const PAYLOAD_PAGE: HeroTablePageDefinition = {
type: 'table',
header: { title: 'Payloads', count: 5 },
columns: [
{ id: 'name', label: 'Name', width: 200, isFirstColumn: true },
{ id: 'payloadType', label: 'Payload Type', width: 150 },
{ id: 'status', label: 'Status', width: 130 },
{ id: 'customer', label: 'Customer', width: 170 },
{ id: 'launch', label: 'Launch', width: 170 },
{ id: 'targetOrbit', label: 'Target Orbit', width: 150 },
{ id: 'massKg', label: 'Mass (kg)', width: 130, align: 'right' },
],
rows: [
{
id: 'starlink-batch-29',
cells: {
name: {
type: 'text',
value: 'Starlink v2 #29',
shortLabel: 'S',
tone: 'blue',
},
payloadType: { type: 'tag', value: 'Satellite' },
status: { type: 'tag', value: 'Deployed' },
customer: {
type: 'relation',
items: [{ name: 'Starlink', shortLabel: 'S', tone: 'blue' }],
},
launch: {
type: 'relation',
items: [{ name: 'Starlink-Grp-6-20', shortLabel: 'S', tone: 'blue' }],
},
targetOrbit: { type: 'text', value: 'LEO 550km' },
massKg: { type: 'number', value: '18,000' },
},
},
{
id: 'orion-artemis',
cells: {
name: {
type: 'text',
value: 'Orion Capsule',
shortLabel: 'O',
tone: 'amber',
},
payloadType: { type: 'tag', value: 'Crew Capsule' },
status: { type: 'tag', value: 'Integrated' },
customer: {
type: 'relation',
items: [{ name: 'NASA', shortLabel: 'N', tone: 'red' }],
},
launch: {
type: 'relation',
items: [{ name: 'Artemis II', shortLabel: 'A', tone: 'amber' }],
},
targetOrbit: { type: 'text', value: 'Lunar Transit' },
massKg: { type: 'number', value: '22,000' },
},
},
{
id: 'dragon-crs-29',
cells: {
name: {
type: 'text',
value: 'Dragon CRS-29',
shortLabel: 'D',
tone: 'blue',
},
payloadType: { type: 'tag', value: 'Cargo' },
status: { type: 'tag', value: 'Launched' },
customer: {
type: 'relation',
items: [{ name: 'NASA', shortLabel: 'N', tone: 'red' }],
},
launch: {
type: 'relation',
items: [{ name: 'CRS-29', shortLabel: 'C', tone: 'blue' }],
},
targetOrbit: { type: 'text', value: 'LEO' },
massKg: { type: 'number', value: '12,500' },
},
},
{
id: 'psyche-probe',
cells: {
name: {
type: 'text',
value: 'Psyche Probe',
shortLabel: 'P',
tone: 'purple',
},
payloadType: { type: 'tag', value: 'Probe' },
status: { type: 'tag', value: 'Deployed' },
customer: {
type: 'relation',
items: [{ name: 'NASA', shortLabel: 'N', tone: 'red' }],
},
launch: {
type: 'relation',
items: [{ name: 'Psyche', shortLabel: 'P', tone: 'purple' }],
},
targetOrbit: { type: 'text', value: 'Asteroid belt' },
massKg: { type: 'number', value: '2,747' },
},
},
{
id: 'euclid-observatory',
cells: {
name: {
type: 'text',
value: 'Euclid Observatory',
shortLabel: 'E',
tone: 'teal',
},
payloadType: { type: 'tag', value: 'Satellite' },
status: { type: 'tag', value: 'Deployed' },
customer: {
type: 'relation',
items: [{ name: 'ESA', shortLabel: 'E', tone: 'teal' }],
},
launch: {
type: 'relation',
items: [{ name: 'Euclid', shortLabel: 'E', tone: 'teal' }],
},
targetOrbit: { type: 'text', value: 'Sun-Earth L2' },
massKg: { type: 'number', value: '2,160' },
},
},
],
};
const LAUNCH_SITE_PAGE: HeroTablePageDefinition = {
type: 'table',
header: { title: 'Launch sites', count: 5 },
columns: [
{ id: 'name', label: 'Name', width: 220, isFirstColumn: true },
{ id: 'siteCode', label: 'Site Code', width: 140 },
{ id: 'padName', label: 'Pad Name', width: 180 },
{ id: 'country', label: 'Country', width: 140 },
{ id: 'siteStatus', label: 'Site Status', width: 150 },
],
rows: [
{
id: 'ksc-39a',
cells: {
name: {
type: 'text',
value: 'Kennedy LC-39A',
shortLabel: 'K',
tone: 'red',
},
siteCode: { type: 'text', value: 'KSC-39A' },
padName: { type: 'text', value: 'Launch Complex 39A' },
country: { type: 'text', value: 'United States' },
siteStatus: { type: 'tag', value: 'Active' },
},
},
{
id: 'ccsfs-slc-40',
cells: {
name: {
type: 'text',
value: 'Cape Canaveral SLC-40',
shortLabel: 'C',
tone: 'red',
},
siteCode: { type: 'text', value: 'CCSFS-40' },
padName: { type: 'text', value: 'Space Launch Complex 40' },
country: { type: 'text', value: 'United States' },
siteStatus: { type: 'tag', value: 'Active' },
},
},
{
id: 'starbase',
cells: {
name: {
type: 'text',
value: 'Starbase',
shortLabel: 'S',
tone: 'orange',
},
siteCode: { type: 'text', value: 'SB-01' },
padName: { type: 'text', value: 'Orbital Launch Pad A' },
country: { type: 'text', value: 'United States' },
siteStatus: { type: 'tag', value: 'Active' },
},
},
{
id: 'vandenberg-slc-4e',
cells: {
name: {
type: 'text',
value: 'Vandenberg SLC-4E',
shortLabel: 'V',
tone: 'purple',
},
siteCode: { type: 'text', value: 'VSFB-4E' },
padName: { type: 'text', value: 'Space Launch Complex 4E' },
country: { type: 'text', value: 'United States' },
siteStatus: { type: 'tag', value: 'Active' },
},
},
{
id: 'kourou-ela-4',
cells: {
name: {
type: 'text',
value: 'Kourou ELA-4',
shortLabel: 'K',
tone: 'teal',
},
siteCode: { type: 'text', value: 'CSG-4' },
padName: { type: 'text', value: 'Ensemble de Lancement 4' },
country: { type: 'text', value: 'French Guiana' },
siteStatus: { type: 'tag', value: 'Active' },
},
},
],
};
export const LAUNCH_SIDEBAR_ITEM: HeroSidebarItem = {
id: 'launches',
label: 'Launches',
icon: { kind: 'tabler', name: 'calendarEvent', tone: 'violet' },
page: LAUNCH_PAGE,
};
export const PAYLOAD_SIDEBAR_ITEM: HeroSidebarItem = {
id: 'payloads',
label: 'Payloads',
icon: { kind: 'tabler', name: 'planet', tone: 'violet' },
page: PAYLOAD_PAGE,
};
export const LAUNCH_SITE_SIDEBAR_ITEM: HeroSidebarItem = {
id: 'launch-sites',
label: 'Launch sites',
icon: { kind: 'tabler', name: 'mapPin', tone: 'violet' },
page: LAUNCH_SITE_PAGE,
};
// Id used by the chat to highlight the already-existing standard Companies
// sidebar item (we reuse it as the customer object instead of creating a new
// Customer object).
export const COMPANIES_ITEM_ID = 'companies';
export const COMPANIES_ITEM_LABEL = 'Companies';
// Ordered pairs used by the chat stream to append objects to the sidebar in
// the same order they're mentioned in the assistant's first paragraph. The
// Customer step is intentionally absent — it reuses the standard Companies
// object that's already in the sidebar.
export const CRM_OBJECT_SEQUENCE: ReadonlyArray<{
id: string;
label: string;
sidebarItem: HeroSidebarItem;
}> = [
{
id: ROCKET_ITEM_ID,
label: ROCKET_ITEM_LABEL,
sidebarItem: ROCKET_SIDEBAR_ITEM,
},
{
id: LAUNCH_SIDEBAR_ITEM.id,
label: LAUNCH_SIDEBAR_ITEM.label,
sidebarItem: LAUNCH_SIDEBAR_ITEM,
},
{
id: PAYLOAD_SIDEBAR_ITEM.id,
label: PAYLOAD_SIDEBAR_ITEM.label,
sidebarItem: PAYLOAD_SIDEBAR_ITEM,
},
{
id: LAUNCH_SITE_SIDEBAR_ITEM.id,
label: LAUNCH_SITE_SIDEBAR_ITEM.label,
sidebarItem: LAUNCH_SITE_SIDEBAR_ITEM,
},
];

View file

@ -0,0 +1,16 @@
// Shared box-shadow tokens for floating windows inside the hero (Terminal +
// Twenty app window). Defined once so both shells stay visually consistent.
export const WINDOW_SHADOWS = {
// Default resting state — the window is on-screen but not being interacted
// with and not the frontmost window.
resting: '0 10px 64px 0 rgba(0, 0, 0, 0.2)',
// Elevated state — applied while the window is being dragged or resized, or
// when it's the active / frontmost window. Slightly stronger diffuse shadow
// plus a tight close shadow for depth.
elevated:
'0 14px 80px 0 rgba(0, 0, 0, 0.26), 0 4px 12px 0 rgba(0, 0, 0, 0.08)',
// Mobile resting shadow — lighter so the stacked window underneath isn't
// washed out to a grey by the overlapping shadow.
mobileResting: '0 4px 16px 0 rgba(0, 0, 0, 0.08)',
mobileElevated: '0 6px 20px 0 rgba(0, 0, 0, 0.12)',
} as const;

View file

@ -1,23 +1,41 @@
import { Container } from '@/design-system/components';
import { IllustrationMount } from '@/illustrations';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
const StyledSection = styled.section`
background-image: url('/images/shared/light-noise.webp');
min-width: 0;
overflow: clip;
padding-bottom: ${theme.spacing(6)};
position: relative;
width: 100%;
`;
const StyledBackground = styled.div`
bottom: 0;
left: -20%;
overflow: clip;
pointer-events: none;
position: absolute;
right: -20%;
top: 0;
z-index: 0;
`;
const StyledContainer = styled(Container)`
display: grid;
grid-template-columns: minmax(0, 1fr);
justify-items: center;
min-width: 0;
position: relative;
text-align: center;
padding-top: ${theme.spacing(7.5)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
row-gap: ${theme.spacing(6)};
z-index: 1;
@media (min-width: ${theme.breakpoints.md}px) {
padding-top: ${theme.spacing(12)};
@ -26,11 +44,24 @@ const StyledContainer = styled(Container)`
}
`;
type RootProps = { backgroundColor: string; children: ReactNode };
type RootProps = {
backgroundColor: string;
children: ReactNode;
showHomeBackground?: boolean;
};
export function Root({ backgroundColor, children }: RootProps) {
export function Root({
backgroundColor,
children,
showHomeBackground = false,
}: RootProps) {
return (
<StyledSection style={{ backgroundColor }}>
{showHomeBackground ? (
<StyledBackground>
<IllustrationMount illustration="heroHomeBackground" />
</StyledBackground>
) : null}
<StyledContainer>{children}</StyledContainer>
</StyledSection>
);

View file

@ -17,24 +17,25 @@ const HOVER_FADE_OUT = 7;
const HALFTONE_SETTINGS = {
animation: {
hoverHalftoneEnabled: false,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.45,
hoverHalftoneEnabled: true,
hoverHalftonePowerShift: 0.62,
hoverHalftoneRadius: 0.6,
hoverHalftoneWidthShift: -0.18,
hoverLightEnabled: true,
hoverLightIntensity: 0.35,
hoverLightRadius: 0.42,
hoverLightEnabled: false,
hoverLightIntensity: 0.12,
hoverLightRadius: 0.8,
waveAmount: 2,
waveEnabled: false,
waveSpeed: 1,
},
halftone: {
dashColor: '#868686',
hoverDashColor: '#F5F5F5',
imageContrast: 1,
power: 0.5,
scale: 8,
width: 0.3,
dashColor: '#dddddd',
hoverDashColor: '#FFF',
imageContrast: 1.12,
minimumTone: 0.26,
power: 0.18,
scale: 12,
width: 0.72,
},
};
@ -95,6 +96,7 @@ const halftoneFragmentShader = /* glsl */ `
uniform float s_4;
uniform vec3 dashColor;
uniform vec3 hoverDashColor;
uniform float minimumTone;
uniform float time;
uniform float waveAmount;
uniform float waveSpeed;
@ -208,15 +210,17 @@ const halftoneFragmentShader = /* glsl */ `
);
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
float bandRadius = clamp(
float tonalAverage = (
(
sceneSample.r +
sceneSample.g +
sceneSample.b +
localPower * length(vec2(0.5))
) *
(1.0 / 3.0) +
lightLift,
(1.0 / 3.0)
) + lightLift;
float bandRadius = clamp(
max(tonalAverage, minimumTone),
0.0,
1.0
) * 1.86 * 0.5;
@ -394,6 +398,7 @@ async function mountHalftoneCanvas({
logicalResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
minimumTone: { value: HALFTONE_SETTINGS.halftone.minimumTone },
s_3: { value: HALFTONE_SETTINGS.halftone.power },
s_4: { value: HALFTONE_SETTINGS.halftone.width },
tScene: { value: sceneTarget.texture },

View file

@ -13,7 +13,8 @@ import {
import { scrollProgressToHomeStepperLottieFrame } from '@/sections/HomeStepper/utils/home-stepper-lottie-frame-map';
import { theme } from '@/theme';
export const HOME_STEPPER_LOTTIE_SRC = '/lottie/stepper/stepper.lottie';
export const HOME_STEPPER_LOTTIE_SRC =
'/lottie/stepper/stepper.lottie?v=data-model-icon-white-3';
const LottieSlot = styled.div`
box-sizing: border-box;

View file

@ -27,6 +27,7 @@ export function Cta({ scheme }: CtaProps) {
color={buttonColor}
href="https://app.twenty.com/welcome"
label="Log in"
size="small"
type="anchor"
variant="outlined"
/>
@ -34,6 +35,7 @@ export function Cta({ scheme }: CtaProps) {
color={buttonColor}
href="https://app.twenty.com/welcome"
label="Get started"
size="small"
type="anchor"
variant="contained"
/>

View file

@ -18,6 +18,7 @@ const SCROLL_IDLE_TIMEOUT_MS = 150;
const StyledSection = styled.section<{ $isScrolling: boolean }>`
backdrop-filter: blur(10px);
background-image: url('/images/shared/light-noise.webp');
box-shadow: ${({ $isScrolling }) =>
$isScrolling
? '0 1px 3px 0 rgba(0, 0, 0, 0.06)'

View file

@ -14,14 +14,14 @@ const StyledContainer = styled(Container)`
padding-bottom: ${theme.spacing(12)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(12)};
padding-top: ${theme.spacing(6)};
@media (min-width: ${theme.breakpoints.md}px) {
column-gap: ${theme.spacing(4)};
grid-template-columns: 1fr 1fr;
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
padding-top: ${theme.spacing(10)};
padding-top: ${theme.spacing(6)};
padding-bottom: ${theme.spacing(10)};
row-gap: ${theme.spacing(20)};
}

View file

@ -1,13 +1,14 @@
'use client';
import { SHARED_COMPANY_LOGO_URLS } from '@/lib/shared-asset-paths';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
IconAt,
IconBuildingSkyscraper,
IconCheckbox,
IconChevronDown,
IconLink,
IconPlus,
IconUser,
} from '@tabler/icons-react';
import {
useEffect,
@ -31,8 +32,6 @@ const ROW_ENTER_STAGGER_MS = 160;
const COLORS = {
accentBorder: VISUAL_TOKENS.border.color.blue,
accentSurfaceSoft: VISUAL_TOKENS.background.transparent.blue,
avatarBackground: '#1f1f1f',
avatarText: '#ffffff',
background: VISUAL_TOKENS.background.primary,
backgroundSecondary: VISUAL_TOKENS.background.secondary,
borderLight: VISUAL_TOKENS.border.color.light,
@ -51,67 +50,67 @@ const COLORS = {
} as const;
const TABLE_COLUMNS = [
{ id: 'contact', isFirstColumn: true, label: 'Contacts', width: 180 },
{ id: 'segment', isFirstColumn: false, label: 'Segment', width: 132 },
{ id: 'email', isFirstColumn: false, label: 'Email', width: 190 },
{ id: 'company', isFirstColumn: true, label: 'Companies', width: 180 },
{ id: 'type', isFirstColumn: false, label: 'Type', width: 132 },
{ id: 'url', isFirstColumn: false, label: 'Domain', width: 150 },
] as const;
type TableRow = {
contact: string;
email: string;
initials: string;
company: string;
domain: string;
isNew?: boolean;
segment: string;
logoSrc: string;
status: string;
};
const BASE_TABLE_ROWS: ReadonlyArray<TableRow> = [
{
contact: 'Ava Martinez',
email: 'ava@resend.com',
initials: 'AM',
segment: 'VIP',
company: 'Anthropic',
domain: 'anthropic.com',
logoSrc: SHARED_COMPANY_LOGO_URLS.anthropic,
status: 'Customer',
},
{
contact: 'Noah Kim',
email: 'noah@resend.com',
initials: 'NK',
segment: 'Champion',
company: 'Slack',
domain: 'slack.com',
logoSrc: SHARED_COMPANY_LOGO_URLS.slack,
status: 'Customer',
},
{
contact: 'Lena Patel',
email: 'lena@resend.com',
initials: 'LP',
segment: 'Champion',
company: 'Notion',
domain: 'notion.so',
logoSrc: SHARED_COMPANY_LOGO_URLS.notion,
status: 'Customer',
},
{
contact: 'Miles Chen',
email: 'miles@resend.com',
initials: 'MC',
segment: 'Warm',
company: 'Sequoia',
domain: 'sequoiacap.com',
logoSrc: SHARED_COMPANY_LOGO_URLS.sequoia,
status: 'Customer',
},
{
contact: 'Zoe Rivera',
email: 'zoe@resend.com',
initials: 'ZR',
segment: 'Warm',
company: 'Cursor',
domain: 'cursor.com',
logoSrc: SHARED_COMPANY_LOGO_URLS.cursor,
status: 'Customer',
},
];
const EXPANDED_TABLE_ROWS: ReadonlyArray<TableRow> = [
...BASE_TABLE_ROWS,
{
contact: 'Eli Brooks',
email: 'eli@resend.com',
initials: 'EB',
company: 'Twenty',
domain: 'twenty.com',
isNew: true,
segment: 'Prospect',
logoSrc: SHARED_COMPANY_LOGO_URLS.twenty,
status: 'Customer',
},
{
contact: 'Ivy Foster',
email: 'ivy@resend.com',
initials: 'IF',
company: 'Linear',
domain: 'linear.app',
isNew: true,
segment: 'New',
logoSrc: SHARED_COMPANY_LOGO_URLS.linear,
status: 'Customer',
},
];
@ -333,27 +332,17 @@ const LogoBase = styled.div`
align-items: center;
display: inline-flex;
flex: 0 0 auto;
height: 16px;
height: 14px;
justify-content: center;
overflow: hidden;
width: 16px;
width: 14px;
`;
const ContactAvatar = styled.div`
align-items: center;
background: ${COLORS.avatarBackground};
border-radius: 999px;
color: ${COLORS.avatarText};
display: inline-flex;
font-family: ${APP_FONT};
font-size: 8px;
font-weight: ${theme.font.weight.medium};
height: 16px;
justify-content: center;
letter-spacing: 0.02em;
line-height: 1;
text-transform: uppercase;
width: 16px;
const CompanyLogoImage = styled.img`
display: block;
height: 100%;
object-fit: contain;
width: 14px;
`;
const StatusChip = styled.div<{ $edited?: boolean; $hoveredByAlice?: boolean }>`
@ -389,9 +378,9 @@ function HeaderIcon({
}: {
columnId: (typeof TABLE_COLUMNS)[number]['id'];
}) {
if (columnId === 'email') {
if (columnId === 'url') {
return (
<IconAt
<IconLink
aria-hidden
color={COLORS.textTertiary}
size={16}
@ -400,7 +389,7 @@ function HeaderIcon({
);
}
if (columnId === 'segment') {
if (columnId === 'type') {
return (
<IconCheckbox
aria-hidden
@ -412,7 +401,7 @@ function HeaderIcon({
}
return (
<IconUser
<IconBuildingSkyscraper
aria-hidden
color={COLORS.textTertiary}
size={16}
@ -443,7 +432,7 @@ function PlusMini({ size = 12 }: { size?: number }) {
);
}
function ContactCell({ initials, label }: { initials: string; label: string }) {
function CompanyCell({ label, logoSrc }: { label: string; logoSrc: string }) {
return (
<CellHoverAnchor>
<CheckboxContainer>
@ -454,7 +443,12 @@ function ContactCell({ initials, label }: { initials: string; label: string }) {
label={label}
leftComponent={
<LogoBase>
<ContactAvatar aria-hidden>{initials}</ContactAvatar>
<CompanyLogoImage
alt=""
decoding="async"
loading="lazy"
src={logoSrc}
/>
</LogoBase>
}
variant={ChipVariant.Highlighted}
@ -590,7 +584,7 @@ export function LiveDataHeroTable({
<TableViewport
ref={viewportRef}
$dragging={dragging}
aria-label="Interactive preview of the Resend contacts table"
aria-label="Interactive preview of the companies table"
onPointerCancel={endDragging}
onPointerDown={handlePointerDown}
onPointerLeave={endDragging}
@ -644,7 +638,7 @@ export function LiveDataHeroTable({
return (
<DataRow
key={row.contact}
key={row.company}
onMouseEnter={() => setHoveredRowIndex(index)}
onMouseLeave={() =>
setHoveredRowIndex((current) =>
@ -658,7 +652,7 @@ export function LiveDataHeroTable({
$width={TABLE_COLUMNS[0].width}
>
<RowMotion $delayMs={enterDelayMs} $entering={row.isNew}>
<ContactCell initials={row.initials} label={row.contact} />
<CompanyCell label={row.company} logoSrc={row.logoSrc} />
</RowMotion>
</TableCell>
<TableCell $hovered={hovered} $width={TABLE_COLUMNS[1].width}>
@ -669,13 +663,13 @@ export function LiveDataHeroTable({
>
{index === 0 && isFirstTagEdited
? editedStatusLabel
: row.segment}
: row.status}
</StatusChip>
</RowMotion>
</TableCell>
<TableCell $hovered={hovered} $width={TABLE_COLUMNS[2].width}>
<RowMotion $delayMs={enterDelayMs} $entering={row.isNew}>
<LinkCell label={row.email} />
<LinkCell label={row.domain} />
</RowMotion>
</TableCell>
<EmptyFillCell $hovered={hovered} $width={fillerWidth}>

View file

@ -3,11 +3,11 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
IconBuildingSkyscraper,
IconChevronDown,
IconHeartHandshake,
IconList,
IconMail,
IconPlus,
IconUser,
IconX,
} from '@tabler/icons-react';
import { type RefObject, useEffect, useRef, useState } from 'react';
@ -181,7 +181,9 @@ const TablePanel = styled.div<{ $active?: boolean }>`
position: absolute;
right: 0px;
transform: ${({ $active }) =>
`translate3d(0, 0, 0) scale(${$active ? TABLE_PANEL_HOVER_SCALE : 1})`};
`translate3d(0, 0, 0) scale(${
$active ? TABLE_PANEL_HOVER_SCALE : 1
})`};
transform-origin: bottom right;
transition:
box-shadow 260ms cubic-bezier(0.22, 1, 0.36, 1),
@ -708,8 +710,8 @@ export function LiveDataVisual({
pointerTargetRef,
}: LiveDataVisualProps) {
const rootRef = useRef<HTMLDivElement>(null);
const companyFilterRef = useRef<HTMLDivElement>(null);
const opensFilterRef = useRef<HTMLDivElement>(null);
const typeFilterRef = useRef<HTMLDivElement>(null);
const employeesFilterRef = useRef<HTMLDivElement>(null);
const [isBobHovered, setIsBobHovered] = useState(false);
const [isTomHovered, setIsTomHovered] = useState(false);
const [phase, setPhase] = useState<LiveDataPhase>('idle');
@ -781,18 +783,23 @@ export function LiveDataVisual({
useEffect(() => {
const measureAddFilterLefts = () => {
const companyFilter = companyFilterRef.current;
const opensFilter = opensFilterRef.current;
const typeFilter = typeFilterRef.current;
const employeesFilter = employeesFilterRef.current;
if (!companyFilter || !opensFilter || opensFilter.offsetWidth === 0) {
if (
!typeFilter ||
!employeesFilter ||
employeesFilter.offsetWidth === 0
) {
return;
}
const nextLefts = {
docked:
companyFilter.offsetLeft + companyFilter.offsetWidth + FILTER_ROW_GAP,
docked: typeFilter.offsetLeft + typeFilter.offsetWidth + FILTER_ROW_GAP,
parked:
opensFilter.offsetLeft + opensFilter.offsetWidth + FILTER_ROW_GAP,
employeesFilter.offsetLeft +
employeesFilter.offsetWidth +
FILTER_ROW_GAP,
};
setAddFilterLefts((current) =>
@ -830,10 +837,10 @@ export function LiveDataVisual({
phase === 'return-bob' ||
phase === 'settle';
const isBobCursorVisible = active;
const isOpensFilterRemoving = phase === 'remove-filter';
const isOpensFilterVisible =
const isEmployeesFilterRemoving = phase === 'remove-filter';
const isEmployeesFilterVisible =
phase !== 'remove-filter' && phase !== 'return-bob' && phase !== 'settle';
const hasOpensFilterBeenRemoved =
const hasEmployeesFilterBeenRemoved =
phase === 'remove-filter' || phase === 'return-bob' || phase === 'settle';
const isFirstTagRenamed =
phase === 'rename-tag' ||
@ -850,7 +857,7 @@ export function LiveDataVisual({
const addFilterLeft = isAddFilterDocked
? (addFilterLefts?.docked ?? DEFAULT_ADD_FILTER_LEFTS.docked)
: (addFilterLefts?.parked ?? DEFAULT_ADD_FILTER_LEFTS.parked);
const viewCount = hasOpensFilterBeenRemoved ? 11 : 9;
const viewCount = hasEmployeesFilterBeenRemoved ? 11 : 9;
return (
<VisualRoot aria-hidden ref={rootRef}>
@ -888,7 +895,10 @@ export function LiveDataVisual({
$bottom={bobCursor.bottom}
$right={bobCursor.right}
>
<MarkerCursorSlot $pressed={phase === 'remove-filter'} $visible>
<MarkerCursorSlot
$pressed={phase === 'remove-filter'}
$visible
>
<MarkerCursor
color={COLORS.bobCursor}
rotation={bobCursor.rotation}
@ -914,7 +924,7 @@ export function LiveDataVisual({
stroke={TABLER_STROKE}
/>
</ViewSwitcherIcon>
<ViewLabel>All contacts</ViewLabel>
<ViewLabel>All</ViewLabel>
<ViewDot />
<ViewCount>{viewCount}</ViewCount>
</ViewSwitcherLeft>
@ -930,19 +940,19 @@ export function LiveDataVisual({
</ViewRow>
<FilterRow>
<FilterChip ref={companyFilterRef}>
<FilterChip ref={typeFilterRef}>
<FilterChipLabel>
<FilterChipIcon>
<IconBuildingSkyscraper
<IconHeartHandshake
aria-hidden
color={COLORS.blue}
size={14}
stroke={FILTER_ICON_STROKE}
/>
</FilterChipIcon>
<FilterName>Company</FilterName>
<FilterName>Type</FilterName>
</FilterChipLabel>
<FilterValue>is Resend</FilterValue>
<FilterValue>is Customer</FilterValue>
<FilterCloseButton type="button">
<IconX
aria-hidden
@ -954,26 +964,26 @@ export function LiveDataVisual({
</FilterChip>
<FilterChipMotion
ref={opensFilterRef}
$removing={isOpensFilterRemoving}
$visible={isOpensFilterVisible}
ref={employeesFilterRef}
$removing={isEmployeesFilterRemoving}
$visible={isEmployeesFilterVisible}
>
<FilterChip
$pressed={phase === 'remove-filter'}
$removing={isOpensFilterRemoving}
$removing={isEmployeesFilterRemoving}
>
<FilterChipLabel>
<FilterChipIcon>
<IconMail
<IconUser
aria-hidden
color={COLORS.blue}
size={14}
stroke={FILTER_ICON_STROKE}
/>
</FilterChipIcon>
<FilterName>Opens</FilterName>
<FilterName>Employees</FilterName>
</FilterChipLabel>
<FilterValue>{'>5'}</FilterValue>
<FilterValue>{'>500'}</FilterValue>
<FilterCloseButton
$pressed={phase === 'remove-filter'}
type="button"
@ -1010,7 +1020,7 @@ export function LiveDataVisual({
editedStatusLabel={isFirstTagRenamed ? typedTagLabel : ''}
isFirstTagEdited={isFirstTagEdited}
isFirstTagHoveredByAlice={isFirstTagHoveredByAlice}
showExtendedRows={hasOpensFilterBeenRemoved}
showExtendedRows={hasEmployeesFilterBeenRemoved}
/>
</TableBodyArea>
</TablePanel>

View file

@ -2,47 +2,27 @@ import { theme } from '@/theme';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
const StyledBlock = styled.div`
display: flex;
const StyledChip = styled.div`
align-items: center;
gap: ${theme.spacing(2)};
height: 48px;
padding: ${theme.spacing(1)} ${theme.spacing(3.75)} ${theme.spacing(1)}
${theme.spacing(1)};
position: relative;
overflow: clip;
display: inline-flex;
flex-shrink: 0;
width: 157px;
gap: ${theme.spacing(2)};
img {
filter: grayscale(1);
opacity: 0.72;
}
`;
const ShapeWrapper = styled.div`
position: absolute;
inset: 0;
z-index: 0;
`;
const IconWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background-color: ${theme.colors.primary.text[5]};
border-radius: 2px;
position: relative;
overflow: clip;
z-index: 1;
`;
const StyledText = styled.p`
font-family: ${theme.font.family.sans};
const StyledText = styled.span`
color: ${theme.colors.primary.text[80]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
font-size: ${theme.font.size(4)};
line-height: ${theme.lineHeight(5.5)};
color: ${theme.colors.primary.text[100]};
letter-spacing: 0.02em;
line-height: 1;
text-transform: uppercase;
white-space: nowrap;
position: relative;
z-index: 1;
`;
type ClientCountProps = {
@ -51,27 +31,15 @@ type ClientCountProps = {
export function ClientCount({ label }: ClientCountProps) {
return (
<StyledBlock>
<ShapeWrapper>
<NextImage
alt=""
fill
sizes="157px"
src="/images/home/logo-bar/others-shape.svg"
unoptimized
style={{ objectFit: 'fill' }}
/>
</ShapeWrapper>
<IconWrapper>
<NextImage
alt=""
height={21}
src="/images/home/logo-bar/others-icon.svg"
unoptimized
width={22}
/>
</IconWrapper>
<StyledChip>
<NextImage
alt=""
height={14}
src="/images/home/logo-bar/others-icon.svg"
unoptimized
width={14}
/>
<StyledText>{label}</StyledText>
</StyledBlock>
</StyledChip>
);
}

View file

@ -4,21 +4,29 @@ import { styled } from '@linaria/react';
import NextImage from 'next/image';
const StyledLogo = styled.div`
height: 32px;
overflow: clip;
height: 28px;
flex-shrink: 0;
overflow: clip;
position: relative;
width: 64px;
width: 56px;
@media (min-width: ${theme.breakpoints.md}px) {
height: 40px;
width: 80px;
height: 32px;
width: 64px;
}
`;
type LogoProps = TrustedByLogosType;
export function Logo({ fit = 'contain', src }: LogoProps) {
const DEFAULT_GRAY_BRIGHTNESS = 1;
const DEFAULT_GRAY_OPACITY = 0.72;
export function Logo({
fit = 'contain',
grayBrightness = DEFAULT_GRAY_BRIGHTNESS,
grayOpacity = DEFAULT_GRAY_OPACITY,
src,
}: LogoProps) {
return (
<StyledLogo aria-hidden="true">
<NextImage
@ -27,7 +35,12 @@ export function Logo({ fit = 'contain', src }: LogoProps) {
sizes="(min-width: 921px) 80px, 64px"
src={src}
unoptimized
style={{ objectFit: fit, objectPosition: 'center' }}
style={{
filter: `grayscale(1) brightness(${grayBrightness})`,
objectFit: fit,
objectPosition: 'center',
opacity: grayOpacity,
}}
/>
</StyledLogo>
);

View file

@ -1,54 +1,44 @@
import {
type TrustedByClientCountLabelType,
type TrustedByLogosType,
} from '@/sections/TrustedBy/types';
import { type TrustedByLogosType } from '@/sections/TrustedBy/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { ClientCount } from '../ClientCount/ClientCount';
import { Logo } from '../Logo/Logo';
const StyledLogos = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${theme.spacing(5)};
justify-content: center;
position: relative;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
flex-direction: row;
gap: ${theme.spacing(14)};
}
`;
const LogoStrip = styled.div`
align-items: center;
display: grid;
gap: ${theme.spacing(4)} ${theme.spacing(6)};
grid-template-columns: repeat(2, minmax(0, max-content));
gap: ${theme.spacing(4)} ${theme.spacing(4)};
grid-template-columns: repeat(3, minmax(0, max-content));
justify-content: center;
justify-items: center;
& > :last-child:nth-child(3n + 1) {
grid-column: 2;
}
@media (min-width: ${theme.breakpoints.md}px) {
display: flex;
gap: ${theme.spacing(14)};
flex-wrap: wrap;
gap: ${theme.spacing(8)};
justify-content: center;
}
`;
type LogosProps = {
clientCountLabel: TrustedByClientCountLabelType;
logos: TrustedByLogosType[];
};
export function Logos({ clientCountLabel, logos }: LogosProps) {
export function Logos({ logos }: LogosProps) {
return (
<StyledLogos>
<LogoStrip>
{logos.map((logo, index) => (
<Logo fit={logo.fit} key={`${logo.src}-${index}`} src={logo.src} />
))}
</LogoStrip>
<ClientCount label={clientCountLabel.text} />
</StyledLogos>
<LogoStrip>
{logos.map((logo, index) => (
<Logo
fit={logo.fit}
grayBrightness={logo.grayBrightness}
grayOpacity={logo.grayOpacity}
key={`${logo.src}-${index}`}
src={logo.src}
/>
))}
</LogoStrip>
);
}

View file

@ -1,37 +1,144 @@
import { Container } from '@/design-system/components';
import { PlusIcon } from '@/icons';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
import { Children, type ReactNode } from 'react';
const CORNER_OFFSET = '-6px';
const StyledSection = styled.section`
width: 100%;
`;
const StyledContainer = styled(Container)`
display: flex;
flex-direction: column;
gap: ${theme.spacing(6)};
padding-bottom: ${theme.spacing(12)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(12)};
position: relative;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
padding-bottom: ${theme.spacing(16)};
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
padding-top: ${theme.spacing(16)};
}
`;
const StyledContainer = styled(Container)`
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
@media (min-width: ${theme.breakpoints.md}px) {
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
}
`;
const StyledCard = styled.div`
align-items: center;
background-color: ${theme.colors.primary.background[100]};
border: 1px solid ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(2)};
display: flex;
flex-direction: column;
gap: ${theme.spacing(5)};
padding: ${theme.spacing(5)} ${theme.spacing(6)};
position: relative;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: stretch;
flex-direction: row;
gap: 0;
padding: 0 ${theme.spacing(8)};
}
`;
const StyledCell = styled.div`
align-items: center;
display: flex;
justify-content: center;
@media (min-width: ${theme.breakpoints.md}px) {
padding: ${theme.spacing(5)} 0;
& + & {
border-left: 1px solid ${theme.colors.primary.border[10]};
}
}
`;
const StyledLabelCell = styled(StyledCell)`
@media (min-width: ${theme.breakpoints.md}px) {
padding-right: ${theme.spacing(6)};
}
`;
const StyledLogosCell = styled(StyledCell)`
flex: 1 1 auto;
@media (min-width: ${theme.breakpoints.md}px) {
padding-left: ${theme.spacing(6)};
padding-right: ${theme.spacing(6)};
}
`;
const StyledCountCell = styled(StyledCell)`
@media (min-width: ${theme.breakpoints.md}px) {
padding-left: ${theme.spacing(6)};
}
`;
const CornerMarker = styled.span`
align-items: center;
display: flex;
height: 12px;
justify-content: center;
pointer-events: none;
position: absolute;
width: 12px;
`;
const CornerTopLeft = styled(CornerMarker)`
left: ${CORNER_OFFSET};
top: ${CORNER_OFFSET};
`;
const CornerTopRight = styled(CornerMarker)`
right: ${CORNER_OFFSET};
top: ${CORNER_OFFSET};
`;
const CornerBottomLeft = styled(CornerMarker)`
bottom: ${CORNER_OFFSET};
left: ${CORNER_OFFSET};
`;
const CornerBottomRight = styled(CornerMarker)`
bottom: ${CORNER_OFFSET};
right: ${CORNER_OFFSET};
`;
interface RootProps {
children: ReactNode;
}
export function Root({ children }: RootProps) {
const [label, logos, count] = Children.toArray(children);
return (
<StyledSection aria-label="Trusted by leading organizations">
<StyledContainer>{children}</StyledContainer>
<StyledContainer>
<StyledCard>
<CornerTopLeft aria-hidden>
<PlusIcon size={12} strokeColor={theme.colors.highlight[100]} />
</CornerTopLeft>
<CornerTopRight aria-hidden>
<PlusIcon size={12} strokeColor={theme.colors.highlight[100]} />
</CornerTopRight>
<CornerBottomLeft aria-hidden>
<PlusIcon size={12} strokeColor={theme.colors.highlight[100]} />
</CornerBottomLeft>
<CornerBottomRight aria-hidden>
<PlusIcon size={12} strokeColor={theme.colors.highlight[100]} />
</CornerBottomRight>
<StyledLabelCell>{label}</StyledLabelCell>
<StyledLogosCell>{logos}</StyledLogosCell>
<StyledCountCell>{count}</StyledCountCell>
</StyledCard>
</StyledContainer>
</StyledSection>
);
}

View file

@ -1,29 +1,11 @@
import { PlusIcon } from '@/icons';
import { TrustedBySeparatorType } from '@/sections/TrustedBy/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const StyledSeparatorRow = styled.div`
display: flex;
justify-content: center;
width: 100%;
align-items: center;
gap: ${theme.spacing(1.5)};
`;
const SeparatorIcon = styled.span`
display: flex;
align-items: center;
width: 12px;
height: 12px;
flex-shrink: 0;
`;
const SeparatorLine = styled.div`
flex-grow: 1;
flex-shrink: 0;
flex-basis: 0;
height: 1px;
background-color: ${theme.colors.primary.border[10]};
`;
const SeparatorText = styled.p`
@ -42,15 +24,7 @@ type SeparatorProps = { separator: TrustedBySeparatorType };
export function Separator({ separator }: SeparatorProps) {
return (
<StyledSeparatorRow>
<SeparatorIcon aria-hidden>
<PlusIcon size={12} strokeColor={theme.colors.highlight[100]} />
</SeparatorIcon>
<SeparatorLine aria-hidden />
<SeparatorText>{separator.text}</SeparatorText>
<SeparatorLine aria-hidden />
<SeparatorIcon aria-hidden>
<PlusIcon size={12} strokeColor={theme.colors.highlight[100]} />
</SeparatorIcon>
</StyledSeparatorRow>
);
}

View file

@ -1,4 +1,6 @@
export type TrustedByLogosType = {
fit?: 'contain' | 'cover';
grayBrightness?: number;
grayOpacity?: number;
src: string;
};