diff --git a/README.md b/README.md
index 7bfc56b28a..684f28eb2d 100644
--- a/README.md
+++ b/README.md
@@ -6,14 +6,14 @@
The #1 Open-Source CRM
- Website · Documentation · Roadmap · Discord · Figma
+ Website · Documentation · Roadmap · Discord · Figma
-
-
-
+
+
+
@@ -85,17 +85,17 @@ Want to go deeper? Read the
-
-
-
+
+
+
Learn more about apps in doc
-
-
-
+
+
+
Learn more about version control in doc
@@ -103,17 +103,17 @@ Want to go deeper? Read the
-
-
-
+
+
+
Learn more about primitives in doc
-
-
-
+
+
+
Learn more about layouts in doc
@@ -121,17 +121,17 @@ Want to go deeper? Read the
-
-
-
+
+
+
Learn more about AI in doc
-
-
-
+
+
+
Learn more about CRM features in doc
@@ -152,13 +152,13 @@ Want to go deeper? Read the
-
+
-
+
-
+
-
+
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
diff --git a/packages/twenty-website-new/lingui.config.ts b/packages/twenty-website-new/lingui.config.ts
index 1125c1c997..215989deb6 100644
--- a/packages/twenty-website-new/lingui.config.ts
+++ b/packages/twenty-website-new/lingui.config.ts
@@ -2,7 +2,7 @@ import { defineConfig } from '@lingui/conf';
import { formatter } from '@lingui/format-po';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
-import { WEBSITE_LOCALE_LIST } from './src/lib/i18n/website-locale-list';
+import { WEBSITE_LOCALE_LIST } from './src/lib/i18n/utils/website-locale-list';
export default defineConfig({
sourceLocale: SOURCE_LOCALE,
diff --git a/packages/twenty-website-new/next.config.ts b/packages/twenty-website-new/next.config.ts
index fd7b50c4a4..7d09efba6d 100644
--- a/packages/twenty-website-new/next.config.ts
+++ b/packages/twenty-website-new/next.config.ts
@@ -55,6 +55,15 @@ const nextConfig: LinariaConfig = {
source: '/:path*',
headers: SECURITY_HEADERS.map((h) => ({ ...h })),
},
+ {
+ source: '/(images|illustrations|lottie)/:path*',
+ headers: [
+ {
+ key: 'Cache-Control',
+ value: 'public, max-age=31536000, immutable',
+ },
+ ],
+ },
];
},
async redirects() {
@@ -115,13 +124,13 @@ const nextConfig: LinariaConfig = {
permanent: true,
},
{
- source: '/why-twenty',
- destination: '/resources/why-twenty',
+ source: '/resources/why-twenty',
+ destination: '/why-twenty',
permanent: true,
},
{
source: '/story',
- destination: '/resources/why-twenty',
+ destination: '/why-twenty',
permanent: true,
},
{
diff --git a/packages/twenty-website-new/package.json b/packages/twenty-website-new/package.json
index e6b7db6f58..b8b12c9fa3 100644
--- a/packages/twenty-website-new/package.json
+++ b/packages/twenty-website-new/package.json
@@ -12,14 +12,11 @@
"@base-ui/react": "^1.3.0",
"@base-ui/utils": "^0.2.6",
"@calcom/embed-react": "^1.5.3",
- "@gsap/react": "2.1.2",
"@linaria/core": "^7.0.0",
"@linaria/react": "^7.0.1",
"@lingui/core": "^5.1.2",
"@lingui/react": "^5.1.2",
"@lottiefiles/dotlottie-react": "^0.18.10",
- "@react-three/drei": "^10.7.7",
- "@react-three/fiber": "^9.5.0",
"@tabler/icons-react": "^3.41.1",
"@wyw-in-js/babel-preset": "^0.8.1",
"axios": "^1.14.0",
diff --git a/packages/twenty-website-new/public/images/case-studies/header-pattern.png b/packages/twenty-website-new/public/images/case-studies/header-pattern.png
deleted file mode 100644
index d8b36a3c1c..0000000000
Binary files a/packages/twenty-website-new/public/images/case-studies/header-pattern.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/case-studies/header-pattern.webp b/packages/twenty-website-new/public/images/case-studies/header-pattern.webp
new file mode 100644
index 0000000000..8d96f8d9b3
Binary files /dev/null and b/packages/twenty-website-new/public/images/case-studies/header-pattern.webp differ
diff --git a/packages/twenty-website-new/public/images/core/logo-mini.png b/packages/twenty-website-new/public/images/core/logo-mini.png
deleted file mode 100644
index ba655d69b8..0000000000
Binary files a/packages/twenty-website-new/public/images/core/logo-mini.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/core/logo-mini.webp b/packages/twenty-website-new/public/images/core/logo-mini.webp
new file mode 100644
index 0000000000..661a8efe03
Binary files /dev/null and b/packages/twenty-website-new/public/images/core/logo-mini.webp differ
diff --git a/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.png b/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.png
deleted file mode 100644
index 4f10ae59f7..0000000000
Binary files a/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.webp b/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.webp
new file mode 100644
index 0000000000..9aadf9a4ec
Binary files /dev/null and b/packages/twenty-website-new/public/images/home/logo-bar/windmill-logo.webp differ
diff --git a/packages/twenty-website-new/public/images/home/stepper/gears.jpg b/packages/twenty-website-new/public/images/home/stepper/gears.jpg
deleted file mode 100644
index 0c3a0eb39e..0000000000
Binary files a/packages/twenty-website-new/public/images/home/stepper/gears.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/home/stepper/gears.webp b/packages/twenty-website-new/public/images/home/stepper/gears.webp
new file mode 100644
index 0000000000..8dc6f41907
Binary files /dev/null and b/packages/twenty-website-new/public/images/home/stepper/gears.webp differ
diff --git a/packages/twenty-website-new/public/images/og/default.png b/packages/twenty-website-new/public/images/og/default.png
new file mode 100644
index 0000000000..0f924eeb36
Binary files /dev/null and b/packages/twenty-website-new/public/images/og/default.png differ
diff --git a/packages/twenty-website-new/public/images/partner/testimonials/bertrams.jpeg b/packages/twenty-website-new/public/images/partner/testimonials/bertrams.jpeg
deleted file mode 100644
index 7d26679408..0000000000
Binary files a/packages/twenty-website-new/public/images/partner/testimonials/bertrams.jpeg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/partner/testimonials/bertrams.webp b/packages/twenty-website-new/public/images/partner/testimonials/bertrams.webp
new file mode 100644
index 0000000000..f71b5a741c
Binary files /dev/null and b/packages/twenty-website-new/public/images/partner/testimonials/bertrams.webp differ
diff --git a/packages/twenty-website-new/public/images/partner/testimonials/joseph-chiang.jpg b/packages/twenty-website-new/public/images/partner/testimonials/joseph-chiang.jpg
deleted file mode 100644
index 293df084b2..0000000000
Binary files a/packages/twenty-website-new/public/images/partner/testimonials/joseph-chiang.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/partner/testimonials/joseph-chiang.webp b/packages/twenty-website-new/public/images/partner/testimonials/joseph-chiang.webp
new file mode 100644
index 0000000000..28acfff234
Binary files /dev/null and b/packages/twenty-website-new/public/images/partner/testimonials/joseph-chiang.webp differ
diff --git a/packages/twenty-website-new/public/images/partner/testimonials/mike-babiy.png b/packages/twenty-website-new/public/images/partner/testimonials/mike-babiy.png
deleted file mode 100644
index 26cdabed77..0000000000
Binary files a/packages/twenty-website-new/public/images/partner/testimonials/mike-babiy.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/partner/testimonials/mike-babiy.webp b/packages/twenty-website-new/public/images/partner/testimonials/mike-babiy.webp
new file mode 100644
index 0000000000..1a9d1dd5c0
Binary files /dev/null and b/packages/twenty-website-new/public/images/partner/testimonials/mike-babiy.webp differ
diff --git a/packages/twenty-website-new/public/images/partner/testimonials/olivier-reinaud.jpg b/packages/twenty-website-new/public/images/partner/testimonials/olivier-reinaud.jpg
deleted file mode 100644
index d164db974d..0000000000
Binary files a/packages/twenty-website-new/public/images/partner/testimonials/olivier-reinaud.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/partner/testimonials/olivier-reinaud.webp b/packages/twenty-website-new/public/images/partner/testimonials/olivier-reinaud.webp
new file mode 100644
index 0000000000..51f3d1df5d
Binary files /dev/null and b/packages/twenty-website-new/public/images/partner/testimonials/olivier-reinaud.webp differ
diff --git a/packages/twenty-website-new/public/images/pricing/plans/organization-icon.png b/packages/twenty-website-new/public/images/pricing/plans/organization-icon.png
deleted file mode 100644
index d9b73f3bb1..0000000000
Binary files a/packages/twenty-website-new/public/images/pricing/plans/organization-icon.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/pricing/plans/organization-icon.webp b/packages/twenty-website-new/public/images/pricing/plans/organization-icon.webp
new file mode 100644
index 0000000000..65f9a6f788
Binary files /dev/null and b/packages/twenty-website-new/public/images/pricing/plans/organization-icon.webp differ
diff --git a/packages/twenty-website-new/public/images/pricing/plans/pro-icon.png b/packages/twenty-website-new/public/images/pricing/plans/pro-icon.png
deleted file mode 100644
index 33c2fb7b65..0000000000
Binary files a/packages/twenty-website-new/public/images/pricing/plans/pro-icon.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/pricing/plans/pro-icon.webp b/packages/twenty-website-new/public/images/pricing/plans/pro-icon.webp
new file mode 100644
index 0000000000..84b03da335
Binary files /dev/null and b/packages/twenty-website-new/public/images/pricing/plans/pro-icon.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/chromatic.png b/packages/twenty-website-new/public/images/readme/chromatic.png
deleted file mode 100644
index 75beea4d8d..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/chromatic.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/chromatic.webp b/packages/twenty-website-new/public/images/readme/chromatic.webp
new file mode 100644
index 0000000000..84adec3f8b
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/chromatic.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/crowdin.png b/packages/twenty-website-new/public/images/readme/crowdin.png
deleted file mode 100644
index 1291e8a17d..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/crowdin.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/crowdin.webp b/packages/twenty-website-new/public/images/readme/crowdin.webp
new file mode 100644
index 0000000000..2255648c48
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/crowdin.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/data-model-dark.png b/packages/twenty-website-new/public/images/readme/data-model-dark.png
deleted file mode 100644
index ffb30a3b20..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/data-model-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/data-model-dark.webp b/packages/twenty-website-new/public/images/readme/data-model-dark.webp
new file mode 100644
index 0000000000..1232535543
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/data-model-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/data-model-light.png b/packages/twenty-website-new/public/images/readme/data-model-light.png
deleted file mode 100644
index 9eb4efb4df..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/data-model-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/data-model-light.webp b/packages/twenty-website-new/public/images/readme/data-model-light.webp
new file mode 100644
index 0000000000..29c6910b00
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/data-model-light.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/figma-icon.png b/packages/twenty-website-new/public/images/readme/figma-icon.png
deleted file mode 100644
index c6595adfae..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/figma-icon.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/figma-icon.webp b/packages/twenty-website-new/public/images/readme/figma-icon.webp
new file mode 100644
index 0000000000..366acfc1ed
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/figma-icon.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/github-cover-dark.png b/packages/twenty-website-new/public/images/readme/github-cover-dark.png
deleted file mode 100644
index d1218eefaa..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/github-cover-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/github-cover-dark.webp b/packages/twenty-website-new/public/images/readme/github-cover-dark.webp
new file mode 100644
index 0000000000..3ebec8e15e
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/github-cover-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/github-cover-light.png b/packages/twenty-website-new/public/images/readme/github-cover-light.png
deleted file mode 100644
index 17f2c17796..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/github-cover-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/github-cover-light.webp b/packages/twenty-website-new/public/images/readme/github-cover-light.webp
new file mode 100644
index 0000000000..9be4616610
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/github-cover-light.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/greptile.png b/packages/twenty-website-new/public/images/readme/greptile.png
deleted file mode 100644
index 65f79ec270..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/greptile.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/greptile.webp b/packages/twenty-website-new/public/images/readme/greptile.webp
new file mode 100644
index 0000000000..ab269df423
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/greptile.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/hacktoberfest2025.png b/packages/twenty-website-new/public/images/readme/hacktoberfest2025.png
deleted file mode 100644
index bbb0312eb2..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/hacktoberfest2025.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/hacktoberfest2025.webp b/packages/twenty-website-new/public/images/readme/hacktoberfest2025.webp
new file mode 100644
index 0000000000..02f43e3ddf
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/hacktoberfest2025.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/permissions-dark.png b/packages/twenty-website-new/public/images/readme/permissions-dark.png
deleted file mode 100644
index f4f3eb5a6d..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/permissions-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/permissions-dark.webp b/packages/twenty-website-new/public/images/readme/permissions-dark.webp
new file mode 100644
index 0000000000..ac5e8878cf
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/permissions-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/permissions-light.png b/packages/twenty-website-new/public/images/readme/permissions-light.png
deleted file mode 100644
index 0b81f80350..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/permissions-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/permissions-light.webp b/packages/twenty-website-new/public/images/readme/permissions-light.webp
new file mode 100644
index 0000000000..7974aed6f5
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/permissions-light.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/plus-other-features-dark.png b/packages/twenty-website-new/public/images/readme/plus-other-features-dark.png
deleted file mode 100644
index d9bd9be269..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/plus-other-features-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/plus-other-features-dark.webp b/packages/twenty-website-new/public/images/readme/plus-other-features-dark.webp
new file mode 100644
index 0000000000..a3deea9dbd
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/plus-other-features-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/plus-other-features-light.png b/packages/twenty-website-new/public/images/readme/plus-other-features-light.png
deleted file mode 100644
index bb5ac79927..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/plus-other-features-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/plus-other-features-light.webp b/packages/twenty-website-new/public/images/readme/plus-other-features-light.webp
new file mode 100644
index 0000000000..92150d53bf
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/plus-other-features-light.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/product-hunt-banner-dark.png b/packages/twenty-website-new/public/images/readme/product-hunt-banner-dark.png
deleted file mode 100644
index 2341bb8362..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/product-hunt-banner-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/product-hunt-banner-dark.webp b/packages/twenty-website-new/public/images/readme/product-hunt-banner-dark.webp
new file mode 100644
index 0000000000..c3134152e7
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/product-hunt-banner-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/product-hunt-banner-light.png b/packages/twenty-website-new/public/images/readme/product-hunt-banner-light.png
deleted file mode 100644
index e39a4708f0..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/product-hunt-banner-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/product-hunt-banner-light.webp b/packages/twenty-website-new/public/images/readme/product-hunt-banner-light.webp
new file mode 100644
index 0000000000..5c8913de4d
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/product-hunt-banner-light.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/product-hunt-banner.png b/packages/twenty-website-new/public/images/readme/product-hunt-banner.png
deleted file mode 100644
index a57a66b8c5..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/product-hunt-banner.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/product-hunt-banner.webp b/packages/twenty-website-new/public/images/readme/product-hunt-banner.webp
new file mode 100644
index 0000000000..43bbb1cbdb
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/product-hunt-banner.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/sentry.png b/packages/twenty-website-new/public/images/readme/sentry.png
deleted file mode 100644
index 52ac04749f..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/sentry.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/sentry.webp b/packages/twenty-website-new/public/images/readme/sentry.webp
new file mode 100644
index 0000000000..d8679cc1b4
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/sentry.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.png b/packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.png
deleted file mode 100644
index d24a3f9fad..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp b/packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp
new file mode 100644
index 0000000000..d17740e6a4
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png b/packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png
deleted file mode 100644
index c511c9fe1b..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp b/packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp
new file mode 100644
index 0000000000..ff7767181d
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-ai-agents.png b/packages/twenty-website-new/public/images/readme/v2-ai-agents.png
deleted file mode 100644
index a2beefeff3..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-ai-agents.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-ai-agents.webp b/packages/twenty-website-new/public/images/readme/v2-ai-agents.webp
new file mode 100644
index 0000000000..322714f12b
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-ai-agents.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-all-tools-dark.png b/packages/twenty-website-new/public/images/readme/v2-all-tools-dark.png
deleted file mode 100644
index 783b20ae66..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-all-tools-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp b/packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp
new file mode 100644
index 0000000000..ca7589db68
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-all-tools-light.png b/packages/twenty-website-new/public/images/readme/v2-all-tools-light.png
deleted file mode 100644
index e262f9de1a..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-all-tools-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp b/packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp
new file mode 100644
index 0000000000..679090dad1
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-build-apps-dark.png b/packages/twenty-website-new/public/images/readme/v2-build-apps-dark.png
deleted file mode 100644
index eac6b93a1c..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-build-apps-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp b/packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp
new file mode 100644
index 0000000000..9f93b1e62c
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-build-apps-light.png b/packages/twenty-website-new/public/images/readme/v2-build-apps-light.png
deleted file mode 100644
index 6f3d2586b3..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-build-apps-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp b/packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp
new file mode 100644
index 0000000000..8e2c962847
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-build-apps.png b/packages/twenty-website-new/public/images/readme/v2-build-apps.png
deleted file mode 100644
index 6f3d2586b3..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-build-apps.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-build-apps.webp b/packages/twenty-website-new/public/images/readme/v2-build-apps.webp
new file mode 100644
index 0000000000..8e2c962847
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-build-apps.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.png b/packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.png
deleted file mode 100644
index 0867904227..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp b/packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp
new file mode 100644
index 0000000000..627ed655bc
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png b/packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png
deleted file mode 100644
index 8a77212b62..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp b/packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp
new file mode 100644
index 0000000000..dd54c06813
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-crm-tools.png b/packages/twenty-website-new/public/images/readme/v2-crm-tools.png
deleted file mode 100644
index 90e34b4881..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-crm-tools.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-crm-tools.webp b/packages/twenty-website-new/public/images/readme/v2-crm-tools.webp
new file mode 100644
index 0000000000..f218d6802c
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-crm-tools.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-tools-dark.png b/packages/twenty-website-new/public/images/readme/v2-tools-dark.png
deleted file mode 100644
index 16bba7e005..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-tools-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-tools-dark.webp b/packages/twenty-website-new/public/images/readme/v2-tools-dark.webp
new file mode 100644
index 0000000000..4851348b52
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-tools-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-tools-light.png b/packages/twenty-website-new/public/images/readme/v2-tools-light.png
deleted file mode 100644
index ee284b3f5a..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-tools-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-tools-light.webp b/packages/twenty-website-new/public/images/readme/v2-tools-light.webp
new file mode 100644
index 0000000000..bf3b882788
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-tools-light.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-tools.png b/packages/twenty-website-new/public/images/readme/v2-tools.png
deleted file mode 100644
index 6e5e9c4540..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-tools.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-tools.webp b/packages/twenty-website-new/public/images/readme/v2-tools.webp
new file mode 100644
index 0000000000..dae7bc11b1
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-tools.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-version-control-dark.png b/packages/twenty-website-new/public/images/readme/v2-version-control-dark.png
deleted file mode 100644
index c90a843b28..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-version-control-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp b/packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp
new file mode 100644
index 0000000000..a99d790c6b
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-version-control-light.png b/packages/twenty-website-new/public/images/readme/v2-version-control-light.png
deleted file mode 100644
index db6d8c3209..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-version-control-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-version-control-light.webp b/packages/twenty-website-new/public/images/readme/v2-version-control-light.webp
new file mode 100644
index 0000000000..324f025d6a
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-version-control-light.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-version-control.png b/packages/twenty-website-new/public/images/readme/v2-version-control.png
deleted file mode 100644
index db6d8c3209..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/v2-version-control.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/v2-version-control.webp b/packages/twenty-website-new/public/images/readme/v2-version-control.webp
new file mode 100644
index 0000000000..324f025d6a
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/v2-version-control.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/views-dark.png b/packages/twenty-website-new/public/images/readme/views-dark.png
deleted file mode 100644
index 3311354cb3..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/views-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/views-dark.webp b/packages/twenty-website-new/public/images/readme/views-dark.webp
new file mode 100644
index 0000000000..11f12d9481
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/views-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/views-light.png b/packages/twenty-website-new/public/images/readme/views-light.png
deleted file mode 100644
index e513c6a427..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/views-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/views-light.webp b/packages/twenty-website-new/public/images/readme/views-light.webp
new file mode 100644
index 0000000000..9154c0502d
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/views-light.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/workflows-dark.png b/packages/twenty-website-new/public/images/readme/workflows-dark.png
deleted file mode 100644
index 7a4f62ad22..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/workflows-dark.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/workflows-dark.webp b/packages/twenty-website-new/public/images/readme/workflows-dark.webp
new file mode 100644
index 0000000000..0112690e61
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/workflows-dark.webp differ
diff --git a/packages/twenty-website-new/public/images/readme/workflows-light.png b/packages/twenty-website-new/public/images/readme/workflows-light.png
deleted file mode 100644
index edad0e935f..0000000000
Binary files a/packages/twenty-website-new/public/images/readme/workflows-light.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/readme/workflows-light.webp b/packages/twenty-website-new/public/images/readme/workflows-light.webp
new file mode 100644
index 0000000000..529e3d943f
Binary files /dev/null and b/packages/twenty-website-new/public/images/readme/workflows-light.webp differ
diff --git a/packages/twenty-website-new/public/images/releases/1.13/1.13.0-stop-workflow-button.png b/packages/twenty-website-new/public/images/releases/1.13/1.13.0-stop-workflow-button.png
deleted file mode 100644
index 9ad17467d5..0000000000
Binary files a/packages/twenty-website-new/public/images/releases/1.13/1.13.0-stop-workflow-button.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/releases/1.13/1.13.0-stop-workflow-button.webp b/packages/twenty-website-new/public/images/releases/1.13/1.13.0-stop-workflow-button.webp
new file mode 100644
index 0000000000..18fc466d98
Binary files /dev/null and b/packages/twenty-website-new/public/images/releases/1.13/1.13.0-stop-workflow-button.webp differ
diff --git a/packages/twenty-website-new/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.png b/packages/twenty-website-new/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.png
deleted file mode 100644
index 4efcf31802..0000000000
Binary files a/packages/twenty-website-new/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.webp b/packages/twenty-website-new/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.webp
new file mode 100644
index 0000000000..61fae0e11a
Binary files /dev/null and b/packages/twenty-website-new/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.webp differ
diff --git a/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.png b/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.png
deleted file mode 100644
index 2473ba7063..0000000000
Binary files a/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.webp b/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.webp
index 03cbf3f29e..9233b6e28e 100644
Binary files a/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.webp and b/packages/twenty-website-new/public/images/releases/1.15/1.15.0-updated-by.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/a16z.png b/packages/twenty-website-new/public/images/shared/companies/logos/a16z.png
deleted file mode 100644
index b0d256d5b2..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/a16z.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/a16z.webp b/packages/twenty-website-new/public/images/shared/companies/logos/a16z.webp
new file mode 100644
index 0000000000..43798acb0f
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/a16z.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/accel.png b/packages/twenty-website-new/public/images/shared/companies/logos/accel.png
deleted file mode 100644
index 19ce7ca692..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/accel.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/accel.webp b/packages/twenty-website-new/public/images/shared/companies/logos/accel.webp
new file mode 100644
index 0000000000..7e61057680
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/accel.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/airbnb.png b/packages/twenty-website-new/public/images/shared/companies/logos/airbnb.png
deleted file mode 100644
index 7582b89816..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/airbnb.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/airbnb.webp b/packages/twenty-website-new/public/images/shared/companies/logos/airbnb.webp
new file mode 100644
index 0000000000..2c9fa7324f
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/airbnb.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/airtable.png b/packages/twenty-website-new/public/images/shared/companies/logos/airtable.png
deleted file mode 100644
index f11070ad04..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/airtable.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/airtable.webp b/packages/twenty-website-new/public/images/shared/companies/logos/airtable.webp
new file mode 100644
index 0000000000..9cfe76536f
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/airtable.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/anthropic.png b/packages/twenty-website-new/public/images/shared/companies/logos/anthropic.png
deleted file mode 100644
index 119e1b49a1..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/anthropic.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/anthropic.webp b/packages/twenty-website-new/public/images/shared/companies/logos/anthropic.webp
new file mode 100644
index 0000000000..9156a40778
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/anthropic.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/apple-1977.png b/packages/twenty-website-new/public/images/shared/companies/logos/apple-1977.png
deleted file mode 100644
index 14b2a0b2a6..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/apple-1977.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/apple-1977.webp b/packages/twenty-website-new/public/images/shared/companies/logos/apple-1977.webp
new file mode 100644
index 0000000000..131499f5bc
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/apple-1977.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/apple.png b/packages/twenty-website-new/public/images/shared/companies/logos/apple.png
deleted file mode 100644
index a5ce06c27c..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/apple.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/apple.webp b/packages/twenty-website-new/public/images/shared/companies/logos/apple.webp
new file mode 100644
index 0000000000..ce4717bcf6
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/apple.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/calendar.png b/packages/twenty-website-new/public/images/shared/companies/logos/calendar.png
deleted file mode 100644
index b2b39108d6..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/calendar.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/calendar.webp b/packages/twenty-website-new/public/images/shared/companies/logos/calendar.webp
new file mode 100644
index 0000000000..e1f5c91423
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/calendar.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/claude.png b/packages/twenty-website-new/public/images/shared/companies/logos/claude.png
deleted file mode 100644
index b5913ed37e..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/claude.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/claude.webp b/packages/twenty-website-new/public/images/shared/companies/logos/claude.webp
new file mode 100644
index 0000000000..79640933cb
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/claude.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/cursor.png b/packages/twenty-website-new/public/images/shared/companies/logos/cursor.png
deleted file mode 100644
index a21a3c35e7..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/cursor.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/cursor.webp b/packages/twenty-website-new/public/images/shared/companies/logos/cursor.webp
new file mode 100644
index 0000000000..a531ddf91c
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/cursor.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/docusign.png b/packages/twenty-website-new/public/images/shared/companies/logos/docusign.png
deleted file mode 100644
index 8c2c3ff668..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/docusign.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/docusign.webp b/packages/twenty-website-new/public/images/shared/companies/logos/docusign.webp
new file mode 100644
index 0000000000..20ce247b9f
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/docusign.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/figma.png b/packages/twenty-website-new/public/images/shared/companies/logos/figma.png
deleted file mode 100644
index b8123ce8d0..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/figma.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/figma.webp b/packages/twenty-website-new/public/images/shared/companies/logos/figma.webp
new file mode 100644
index 0000000000..ede071c202
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/figma.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/founders-fund.png b/packages/twenty-website-new/public/images/shared/companies/logos/founders-fund.png
deleted file mode 100644
index 399c73f8cf..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/founders-fund.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/founders-fund.webp b/packages/twenty-website-new/public/images/shared/companies/logos/founders-fund.webp
new file mode 100644
index 0000000000..5affdc758e
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/founders-fund.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/github.png b/packages/twenty-website-new/public/images/shared/companies/logos/github.png
deleted file mode 100644
index 5688f1d670..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/github.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/github.webp b/packages/twenty-website-new/public/images/shared/companies/logos/github.webp
new file mode 100644
index 0000000000..df3c08d959
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/github.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/gmail.png b/packages/twenty-website-new/public/images/shared/companies/logos/gmail.png
deleted file mode 100644
index f6620b5ec3..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/gmail.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/gmail.webp b/packages/twenty-website-new/public/images/shared/companies/logos/gmail.webp
new file mode 100644
index 0000000000..36ae973903
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/gmail.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/google.png b/packages/twenty-website-new/public/images/shared/companies/logos/google.png
deleted file mode 100644
index 5652d06705..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/google.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/google.webp b/packages/twenty-website-new/public/images/shared/companies/logos/google.webp
new file mode 100644
index 0000000000..22e0ba91ad
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/google.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/hubspot.png b/packages/twenty-website-new/public/images/shared/companies/logos/hubspot.png
deleted file mode 100644
index cc52cc8f51..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/hubspot.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/hubspot.webp b/packages/twenty-website-new/public/images/shared/companies/logos/hubspot.webp
new file mode 100644
index 0000000000..795f325e32
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/hubspot.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/kleiner-perkins.png b/packages/twenty-website-new/public/images/shared/companies/logos/kleiner-perkins.png
deleted file mode 100644
index 02c9c1a829..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/kleiner-perkins.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/kleiner-perkins.webp b/packages/twenty-website-new/public/images/shared/companies/logos/kleiner-perkins.webp
new file mode 100644
index 0000000000..0af40ceb21
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/kleiner-perkins.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/lemlist.png b/packages/twenty-website-new/public/images/shared/companies/logos/lemlist.png
deleted file mode 100644
index 230a0228b9..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/lemlist.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/lemlist.webp b/packages/twenty-website-new/public/images/shared/companies/logos/lemlist.webp
new file mode 100644
index 0000000000..01629d282f
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/lemlist.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/linkedin.png b/packages/twenty-website-new/public/images/shared/companies/logos/linkedin.png
deleted file mode 100644
index 17e4a661a6..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/linkedin.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/linkedin.webp b/packages/twenty-website-new/public/images/shared/companies/logos/linkedin.webp
new file mode 100644
index 0000000000..a9c994ce68
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/linkedin.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/mailchimp.png b/packages/twenty-website-new/public/images/shared/companies/logos/mailchimp.png
deleted file mode 100644
index dac60f27f1..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/mailchimp.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/mailchimp.webp b/packages/twenty-website-new/public/images/shared/companies/logos/mailchimp.webp
new file mode 100644
index 0000000000..7bc14f1c10
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/mailchimp.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/meet.png b/packages/twenty-website-new/public/images/shared/companies/logos/meet.png
deleted file mode 100644
index 66638552e2..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/meet.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/meet.webp b/packages/twenty-website-new/public/images/shared/companies/logos/meet.webp
new file mode 100644
index 0000000000..eb0f50833c
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/meet.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/metabase.png b/packages/twenty-website-new/public/images/shared/companies/logos/metabase.png
deleted file mode 100644
index de46b9370c..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/metabase.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/metabase.webp b/packages/twenty-website-new/public/images/shared/companies/logos/metabase.webp
new file mode 100644
index 0000000000..99acc24b5d
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/metabase.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/microsoft.png b/packages/twenty-website-new/public/images/shared/companies/logos/microsoft.png
deleted file mode 100644
index 265ee5498f..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/microsoft.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/microsoft.webp b/packages/twenty-website-new/public/images/shared/companies/logos/microsoft.webp
new file mode 100644
index 0000000000..23c8ed4e74
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/microsoft.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/notion.png b/packages/twenty-website-new/public/images/shared/companies/logos/notion.png
deleted file mode 100644
index 4b15aaf862..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/notion.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/notion.webp b/packages/twenty-website-new/public/images/shared/companies/logos/notion.webp
new file mode 100644
index 0000000000..c4118c38a3
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/notion.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/okta.png b/packages/twenty-website-new/public/images/shared/companies/logos/okta.png
deleted file mode 100644
index 90d08aa440..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/okta.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/okta.webp b/packages/twenty-website-new/public/images/shared/companies/logos/okta.webp
new file mode 100644
index 0000000000..143c34354c
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/okta.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/openai.png b/packages/twenty-website-new/public/images/shared/companies/logos/openai.png
deleted file mode 100644
index 2605bfef9c..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/openai.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/openai.webp b/packages/twenty-website-new/public/images/shared/companies/logos/openai.webp
new file mode 100644
index 0000000000..fb836b64be
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/openai.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/outlook.png b/packages/twenty-website-new/public/images/shared/companies/logos/outlook.png
deleted file mode 100644
index 4c2dceff7e..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/outlook.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/outlook.webp b/packages/twenty-website-new/public/images/shared/companies/logos/outlook.webp
new file mode 100644
index 0000000000..fb5315b804
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/outlook.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/outreach.png b/packages/twenty-website-new/public/images/shared/companies/logos/outreach.png
deleted file mode 100644
index 128b0526fa..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/outreach.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/outreach.webp b/packages/twenty-website-new/public/images/shared/companies/logos/outreach.webp
new file mode 100644
index 0000000000..139c36dcf5
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/outreach.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/postgresql.png b/packages/twenty-website-new/public/images/shared/companies/logos/postgresql.png
deleted file mode 100644
index 4a9c0422bb..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/postgresql.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/postgresql.webp b/packages/twenty-website-new/public/images/shared/companies/logos/postgresql.webp
new file mode 100644
index 0000000000..d73ffe7182
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/postgresql.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/qonto.png b/packages/twenty-website-new/public/images/shared/companies/logos/qonto.png
deleted file mode 100644
index bd8bb18ac3..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/qonto.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/qonto.webp b/packages/twenty-website-new/public/images/shared/companies/logos/qonto.webp
new file mode 100644
index 0000000000..adeccdc9e3
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/qonto.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/salesforce.png b/packages/twenty-website-new/public/images/shared/companies/logos/salesforce.png
deleted file mode 100644
index 9695e7b2e6..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/salesforce.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/salesforce.webp b/packages/twenty-website-new/public/images/shared/companies/logos/salesforce.webp
new file mode 100644
index 0000000000..7bd40a416c
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/salesforce.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/segment.png b/packages/twenty-website-new/public/images/shared/companies/logos/segment.png
deleted file mode 100644
index 54a032342f..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/segment.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/segment.webp b/packages/twenty-website-new/public/images/shared/companies/logos/segment.webp
new file mode 100644
index 0000000000..f551e54b3a
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/segment.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/sequoia.png b/packages/twenty-website-new/public/images/shared/companies/logos/sequoia.png
deleted file mode 100644
index dd2ff84237..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/sequoia.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/sequoia.webp b/packages/twenty-website-new/public/images/shared/companies/logos/sequoia.webp
new file mode 100644
index 0000000000..bb3f60aca1
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/sequoia.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/slack.png b/packages/twenty-website-new/public/images/shared/companies/logos/slack.png
deleted file mode 100644
index 23523fc912..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/slack.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/slack.webp b/packages/twenty-website-new/public/images/shared/companies/logos/slack.webp
new file mode 100644
index 0000000000..68c4424b38
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/slack.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/stripe.png b/packages/twenty-website-new/public/images/shared/companies/logos/stripe.png
deleted file mode 100644
index cc3c0de27a..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/stripe.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/stripe.webp b/packages/twenty-website-new/public/images/shared/companies/logos/stripe.webp
new file mode 100644
index 0000000000..6fe0ec2222
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/stripe.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/tally.png b/packages/twenty-website-new/public/images/shared/companies/logos/tally.png
deleted file mode 100644
index bd9a5ad362..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/tally.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/tally.webp b/packages/twenty-website-new/public/images/shared/companies/logos/tally.webp
new file mode 100644
index 0000000000..ad311546be
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/tally.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/twenty.png b/packages/twenty-website-new/public/images/shared/companies/logos/twenty.png
deleted file mode 100644
index adfb6af341..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/twenty.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/twenty.webp b/packages/twenty-website-new/public/images/shared/companies/logos/twenty.webp
new file mode 100644
index 0000000000..4873fd10fc
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/twenty.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/whatsapp.png b/packages/twenty-website-new/public/images/shared/companies/logos/whatsapp.png
deleted file mode 100644
index d24852f3e8..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/whatsapp.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/whatsapp.webp b/packages/twenty-website-new/public/images/shared/companies/logos/whatsapp.webp
new file mode 100644
index 0000000000..ea8ae29045
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/whatsapp.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/zapier.png b/packages/twenty-website-new/public/images/shared/companies/logos/zapier.png
deleted file mode 100644
index 6d275c19c8..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/companies/logos/zapier.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/companies/logos/zapier.webp b/packages/twenty-website-new/public/images/shared/companies/logos/zapier.webp
new file mode 100644
index 0000000000..8ba68a0424
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/companies/logos/zapier.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-gray.png b/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-gray.png
deleted file mode 100644
index 8d25e59b0f..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-gray.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-gray.webp b/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-gray.webp
new file mode 100644
index 0000000000..97549d13a7
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-gray.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-white.png b/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-white.png
deleted file mode 100644
index 20336ce301..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-white.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-white.webp b/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-white.webp
new file mode 100644
index 0000000000..1cba624d75
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/engagement-band/halftone-on-white.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/menu/developers-preview.png b/packages/twenty-website-new/public/images/shared/menu/developers-preview.png
deleted file mode 100644
index 2017cef4da..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/menu/developers-preview.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/menu/developers-preview.webp b/packages/twenty-website-new/public/images/shared/menu/developers-preview.webp
new file mode 100644
index 0000000000..b521308da6
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/menu/developers-preview.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/menu/partners-preview.png b/packages/twenty-website-new/public/images/shared/menu/partners-preview.png
deleted file mode 100644
index e75a4a9272..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/menu/partners-preview.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/menu/partners-preview.webp b/packages/twenty-website-new/public/images/shared/menu/partners-preview.webp
new file mode 100644
index 0000000000..2bbda4d514
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/menu/partners-preview.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/menu/user-guide-preview.png b/packages/twenty-website-new/public/images/shared/menu/user-guide-preview.png
deleted file mode 100644
index 4f67fff238..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/menu/user-guide-preview.png and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/menu/user-guide-preview.webp b/packages/twenty-website-new/public/images/shared/menu/user-guide-preview.webp
new file mode 100644
index 0000000000..98e171701c
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/menu/user-guide-preview.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/alexandre-prot.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/alexandre-prot.jpg
deleted file mode 100644
index caa923e848..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/alexandre-prot.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/alexandre-prot.webp b/packages/twenty-website-new/public/images/shared/people/avatars/alexandre-prot.webp
new file mode 100644
index 0000000000..d10a796443
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/alexandre-prot.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-anna.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-anna.jpg
deleted file mode 100644
index 3d400a5258..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-anna.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-anna.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-anna.webp
new file mode 100644
index 0000000000..1e1cca7703
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-anna.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-fabrice.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-fabrice.jpg
deleted file mode 100644
index adc7577e7d..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-fabrice.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-fabrice.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-fabrice.webp
new file mode 100644
index 0000000000..0078252829
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-fabrice.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-felix.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-felix.jpg
deleted file mode 100644
index db8e7c5d16..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-felix.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-felix.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-felix.webp
new file mode 100644
index 0000000000..e8ce7605af
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-felix.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-indira.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-indira.jpg
deleted file mode 100644
index 7f529e3e00..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-indira.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-indira.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-indira.webp
new file mode 100644
index 0000000000..c351b70f86
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-indira.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-laura.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-laura.jpg
deleted file mode 100644
index d09062d97f..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-laura.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-laura.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-laura.webp
new file mode 100644
index 0000000000..edc9b1e221
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-laura.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-mike.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-mike.jpg
deleted file mode 100644
index c4c1a74d50..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-mike.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-mike.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-mike.webp
new file mode 100644
index 0000000000..0c38085fc0
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-mike.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-thomas.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-thomas.jpg
deleted file mode 100644
index f85d3c2d78..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-thomas.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-thomas.webp b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-thomas.webp
new file mode 100644
index 0000000000..79fc11a8d8
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/anonymous-thomas.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ben-chestnut.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/ben-chestnut.jpg
deleted file mode 100644
index 32cd940f2b..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/ben-chestnut.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ben-chestnut.webp b/packages/twenty-website-new/public/images/shared/people/avatars/ben-chestnut.webp
new file mode 100644
index 0000000000..68985a00ab
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/ben-chestnut.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/brian-chesky.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/brian-chesky.jpg
deleted file mode 100644
index ab9376c4c7..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/brian-chesky.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/brian-chesky.webp b/packages/twenty-website-new/public/images/shared/people/avatars/brian-chesky.webp
new file mode 100644
index 0000000000..db95480be0
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/brian-chesky.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/chris-wanstrath.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/chris-wanstrath.jpg
deleted file mode 100644
index 6b883a75df..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/chris-wanstrath.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/chris-wanstrath.webp b/packages/twenty-website-new/public/images/shared/people/avatars/chris-wanstrath.webp
new file mode 100644
index 0000000000..54b2de4f4f
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/chris-wanstrath.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/craig-federighi.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/craig-federighi.jpg
deleted file mode 100644
index 21ee309060..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/craig-federighi.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/craig-federighi.webp b/packages/twenty-website-new/public/images/shared/people/avatars/craig-federighi.webp
new file mode 100644
index 0000000000..0f03518995
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/craig-federighi.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/dario-amodei.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/dario-amodei.jpg
deleted file mode 100644
index 4ffd0685b1..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/dario-amodei.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/dario-amodei.webp b/packages/twenty-website-new/public/images/shared/people/avatars/dario-amodei.webp
new file mode 100644
index 0000000000..fb708daf8b
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/dario-amodei.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/dylan-field.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/dylan-field.jpg
deleted file mode 100644
index 70aa4c32bc..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/dylan-field.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/dylan-field.webp b/packages/twenty-website-new/public/images/shared/people/avatars/dylan-field.webp
new file mode 100644
index 0000000000..dd6af85617
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/dylan-field.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/eddy-cue.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/eddy-cue.jpg
deleted file mode 100644
index b4d1889b66..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/eddy-cue.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/eddy-cue.webp b/packages/twenty-website-new/public/images/shared/people/avatars/eddy-cue.webp
new file mode 100644
index 0000000000..486ce5a535
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/eddy-cue.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ivan-zhao.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/ivan-zhao.jpg
deleted file mode 100644
index cbd6624968..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/ivan-zhao.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ivan-zhao.webp b/packages/twenty-website-new/public/images/shared/people/avatars/ivan-zhao.webp
new file mode 100644
index 0000000000..a27d6ad93e
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/ivan-zhao.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/jeff-williams.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/jeff-williams.jpg
deleted file mode 100644
index 54b0dcd256..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/jeff-williams.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/jeff-williams.webp b/packages/twenty-website-new/public/images/shared/people/avatars/jeff-williams.webp
new file mode 100644
index 0000000000..cf14da400b
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/jeff-williams.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/joe-gebbia.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/joe-gebbia.jpg
deleted file mode 100644
index b2ef366a29..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/joe-gebbia.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/joe-gebbia.webp b/packages/twenty-website-new/public/images/shared/people/avatars/joe-gebbia.webp
new file mode 100644
index 0000000000..f1de37b2bc
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/joe-gebbia.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/katherine-adams.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/katherine-adams.jpg
deleted file mode 100644
index d7d600077a..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/katherine-adams.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/katherine-adams.webp b/packages/twenty-website-new/public/images/shared/people/avatars/katherine-adams.webp
new file mode 100644
index 0000000000..e18ac3037e
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/katherine-adams.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/patrick-collison.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/patrick-collison.jpg
deleted file mode 100644
index c556423d88..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/patrick-collison.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/patrick-collison.webp b/packages/twenty-website-new/public/images/shared/people/avatars/patrick-collison.webp
new file mode 100644
index 0000000000..1ad15e6b8b
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/patrick-collison.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/peter-reinhardt.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/peter-reinhardt.jpg
deleted file mode 100644
index 7fac2cc8c7..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/peter-reinhardt.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/peter-reinhardt.webp b/packages/twenty-website-new/public/images/shared/people/avatars/peter-reinhardt.webp
new file mode 100644
index 0000000000..5a2d4fa192
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/peter-reinhardt.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/peter-thiel.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/peter-thiel.jpg
deleted file mode 100644
index 09fd903b04..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/peter-thiel.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/peter-thiel.webp b/packages/twenty-website-new/public/images/shared/people/avatars/peter-thiel.webp
new file mode 100644
index 0000000000..cddd929e0f
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/peter-thiel.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/phil-schiller.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/phil-schiller.jpg
deleted file mode 100644
index c2a62ec721..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/phil-schiller.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/phil-schiller.webp b/packages/twenty-website-new/public/images/shared/people/avatars/phil-schiller.webp
new file mode 100644
index 0000000000..922a53bbca
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/phil-schiller.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ping-li.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/ping-li.jpg
deleted file mode 100644
index a95052b557..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/ping-li.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ping-li.webp b/packages/twenty-website-new/public/images/shared/people/avatars/ping-li.webp
new file mode 100644
index 0000000000..9eeb3d9592
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/ping-li.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ray-damm.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/ray-damm.jpg
deleted file mode 100644
index 023544cc6c..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/ray-damm.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ray-damm.webp b/packages/twenty-website-new/public/images/shared/people/avatars/ray-damm.webp
new file mode 100644
index 0000000000..dc65b2e1cd
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/ray-damm.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/reid-hoffman.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/reid-hoffman.jpg
deleted file mode 100644
index c8212d35a1..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/reid-hoffman.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/reid-hoffman.webp b/packages/twenty-website-new/public/images/shared/people/avatars/reid-hoffman.webp
new file mode 100644
index 0000000000..8ce8b887bf
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/reid-hoffman.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/roelof-botha.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/roelof-botha.jpg
deleted file mode 100644
index b91bae044c..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/roelof-botha.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/roelof-botha.webp b/packages/twenty-website-new/public/images/shared/people/avatars/roelof-botha.webp
new file mode 100644
index 0000000000..18c392f008
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/roelof-botha.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ryan-roslansky.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/ryan-roslansky.jpg
deleted file mode 100644
index e1d2fd3b1c..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/ryan-roslansky.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/ryan-roslansky.webp b/packages/twenty-website-new/public/images/shared/people/avatars/ryan-roslansky.webp
new file mode 100644
index 0000000000..449311bae2
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/ryan-roslansky.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/steve-anavi.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/steve-anavi.jpg
deleted file mode 100644
index a32e23198c..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/steve-anavi.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/steve-anavi.webp b/packages/twenty-website-new/public/images/shared/people/avatars/steve-anavi.webp
new file mode 100644
index 0000000000..9e7e962b9d
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/steve-anavi.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/stewart-butterfield.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/stewart-butterfield.jpg
deleted file mode 100644
index b0e48f5126..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/stewart-butterfield.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/stewart-butterfield.webp b/packages/twenty-website-new/public/images/shared/people/avatars/stewart-butterfield.webp
new file mode 100644
index 0000000000..f35c93b849
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/stewart-butterfield.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/sundar-pichai.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/sundar-pichai.jpg
deleted file mode 100644
index 3ee9b568ae..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/sundar-pichai.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/sundar-pichai.webp b/packages/twenty-website-new/public/images/shared/people/avatars/sundar-pichai.webp
new file mode 100644
index 0000000000..4d852dc83b
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/sundar-pichai.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/thomas-dohmke.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/thomas-dohmke.jpg
deleted file mode 100644
index 7ad6ecb24d..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/thomas-dohmke.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/thomas-dohmke.webp b/packages/twenty-website-new/public/images/shared/people/avatars/thomas-dohmke.webp
new file mode 100644
index 0000000000..60b0b5b363
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/thomas-dohmke.webp differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/tim-cook.jpg b/packages/twenty-website-new/public/images/shared/people/avatars/tim-cook.jpg
deleted file mode 100644
index 310686555f..0000000000
Binary files a/packages/twenty-website-new/public/images/shared/people/avatars/tim-cook.jpg and /dev/null differ
diff --git a/packages/twenty-website-new/public/images/shared/people/avatars/tim-cook.webp b/packages/twenty-website-new/public/images/shared/people/avatars/tim-cook.webp
new file mode 100644
index 0000000000..8a79b9f3f5
Binary files /dev/null and b/packages/twenty-website-new/public/images/shared/people/avatars/tim-cook.webp differ
diff --git a/packages/twenty-website-new/scripts/check-lottie-frames.mjs b/packages/twenty-website-new/scripts/check-lottie-frames.mjs
index 02e56de4ad..1ef4821b63 100644
--- a/packages/twenty-website-new/scripts/check-lottie-frames.mjs
+++ b/packages/twenty-website-new/scripts/check-lottie-frames.mjs
@@ -23,12 +23,13 @@ const FRAME_MAP_PATH = path.join(
'src',
'sections',
'HomeStepper',
- 'utils',
- 'home-stepper-lottie-frame-map.ts',
+ 'components',
+ 'Visual',
+ 'use-dot-lottie-scroll-sync.ts',
);
const EXPECTED_CONSTANT_REGEX =
- /export\s+const\s+HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES\s*=\s*(\d+)\s*;/;
+ /(?:export\s+)?const\s+HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES\s*=\s*(\d+)\s*;/;
function fail(message) {
// eslint-disable-next-line no-console
diff --git a/packages/twenty-website-new/scripts/check-section-shape.mjs b/packages/twenty-website-new/scripts/check-section-shape.mjs
index ab49bf41a1..8f8106f70f 100644
--- a/packages/twenty-website-new/scripts/check-section-shape.mjs
+++ b/packages/twenty-website-new/scripts/check-section-shape.mjs
@@ -7,15 +7,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SECTIONS_DIR = path.join(ROOT, 'src', 'sections');
-const SECTIONS_USING_NAMED_SLOTS = new Map([
- ['Marquee', new Set(['Heading'])],
- ['TrustedBy', new Set(['Separator', 'Logos', 'ClientCount'])],
-]);
+const SECTIONS_USING_NAMED_SLOTS = new Map([]);
const LEAF_SECTIONS = new Set([
'CaseStudy',
'CaseStudyCatalog',
+ 'ContactCal',
'LegalDocument',
+ 'PartnerApplication',
+ 'Stepper',
]);
async function listSections() {
@@ -47,6 +47,8 @@ async function findBarrel(sectionDir) {
const candidates = [
path.join(sectionDir, 'components', 'index.ts'),
path.join(sectionDir, 'components', 'index.tsx'),
+ path.join(sectionDir, 'index.ts'),
+ path.join(sectionDir, 'index.tsx'),
];
for (const candidate of candidates) {
if (await fileExists(candidate)) return candidate;
@@ -55,8 +57,15 @@ async function findBarrel(sectionDir) {
}
async function findRoot(sectionDir) {
- const candidate = path.join(sectionDir, 'components', 'Root.tsx');
- if (await fileExists(candidate)) return candidate;
+ const sectionName = path.basename(sectionDir);
+ const candidates = [
+ path.join(sectionDir, 'components', 'Root.tsx'),
+ path.join(sectionDir, `${sectionName}.tsx`),
+ path.join(sectionDir, `${sectionName}.ts`),
+ ];
+ for (const candidate of candidates) {
+ if (await fileExists(candidate)) return candidate;
+ }
return null;
}
diff --git a/packages/twenty-website-new/scripts/convert-png-to-webp.mjs b/packages/twenty-website-new/scripts/convert-png-to-webp.mjs
index 227fbab23d..520dcd86c8 100644
--- a/packages/twenty-website-new/scripts/convert-png-to-webp.mjs
+++ b/packages/twenty-website-new/scripts/convert-png-to-webp.mjs
@@ -7,6 +7,8 @@ import sharp from 'sharp';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const imagesRoot = path.join(__dirname, '..', 'public', 'images');
+const CONVERTIBLE_EXTENSIONS = /\.(png|jpe?g)$/i;
+
let totalBefore = 0;
let totalAfter = 0;
let count = 0;
@@ -20,11 +22,11 @@ async function walkAndConvert(directoryPath) {
continue;
}
- if (!entry.name.toLowerCase().endsWith('.png')) {
+ if (!CONVERTIBLE_EXTENSIONS.test(entry.name)) {
continue;
}
- const outputPath = fullPath.replace(/\.png$/i, '.webp');
+ const outputPath = fullPath.replace(CONVERTIBLE_EXTENSIONS, '.webp');
const beforeSize = fs.statSync(fullPath).size;
await sharp(fullPath).webp({ quality: 92 }).toFile(outputPath);
diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/helped.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/helped.data.ts
index f1e83674ed..12e67db252 100644
--- a/packages/twenty-website-new/src/app/[locale]/(home)/helped.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/(home)/helped.data.ts
@@ -1,43 +1,26 @@
import { msg } from '@lingui/core/macro';
-import type { HelpedDataType } from '@/sections/Helped/types/HelpedData';
+import type { HeadingCardType } from '@/sections/Helped';
-export const HELPED_DATA: HelpedDataType = {
- eyebrow: {
- heading: {
- text: msg`In production.`,
- fontFamily: 'sans',
- },
+export const HELPED_CARDS: HeadingCardType[] = [
+ {
+ icon: 'w3villa',
+ heading: msg`Ship a product on Twenty`,
+ body: msg`W3villa built W3Grads for AI mock interviews at scale, with Twenty as the operational backbone.`,
+ illustration: 'target',
+ href: '/customers/w3villa',
},
- cards: [
- {
- icon: 'w3villa',
- heading: { text: msg`Ship a product on Twenty`, fontFamily: 'sans' },
- body: {
- text: msg`W3villa built W3Grads for AI mock interviews at scale, with Twenty as the operational backbone.`,
- },
- illustration: 'target',
- href: '/customers/w3villa',
- },
- {
- icon: 'act-education',
- heading: { text: msg`Own your CRM end to end`, fontFamily: 'sans' },
- body: {
- text: msg`AC&T replaced a shuttered vendor CRM with self-hosted Twenty and cut CRM costs by more than 90%.`,
- },
- illustration: 'spaceship',
- href: '/customers/act-education',
- },
- {
- icon: 'netzero',
- heading: {
- text: msg`Grow with a flexible foundation`,
- fontFamily: 'sans',
- },
- body: {
- text: msg`NetZero runs a modular Twenty setup across carbon credits, ag products, and industrial systems.`,
- },
- illustration: 'money',
- href: '/customers/netzero',
- },
- ],
-};
+ {
+ icon: 'act-education',
+ heading: msg`Own your CRM end to end`,
+ body: msg`AC&T replaced a shuttered vendor CRM with self-hosted Twenty and cut CRM costs by more than 90%.`,
+ illustration: 'spaceship',
+ href: '/customers/act-education',
+ },
+ {
+ icon: 'netzero',
+ heading: msg`Grow with a flexible foundation`,
+ body: msg`NetZero runs a modular Twenty setup across carbon credits, ag products, and industrial systems.`,
+ illustration: 'money',
+ href: '/customers/netzero',
+ },
+];
diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/hero.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/hero.data.ts
index c1aced9162..1d0397e8ab 100644
--- a/packages/twenty-website-new/src/app/[locale]/(home)/hero.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/(home)/hero.data.ts
@@ -1,12 +1,10 @@
-import { msg } from '@lingui/core/macro';
-
import type {
HeroDashboardDataType,
HeroDashboardPageDefinition,
HeroKanbanPageDefinition,
HeroTablePageDefinition,
HeroVisualType,
-} from '@/sections/Hero/types';
+} from '@/sections/Hero';
import { SHARED_PEOPLE_AVATAR_URLS } from '@/content/site/asset-paths';
const PEOPLE_AVATAR_URLS = {
@@ -325,10 +323,6 @@ function createTablePage({
};
}
-export const HERO_COPY = {
- body: msg`Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves.`,
-};
-
export const HERO_DATA: { visual: HeroVisualType } = {
visual: {
workspace: { icon: 'apple', name: 'Apple' },
diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/home-stepper.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/home-stepper.data.ts
deleted file mode 100644
index 348535eadd..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/(home)/home-stepper.data.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { msg } from '@lingui/core/macro';
-import type { HomeStepperDataType } from '@/sections/HomeStepper/types';
-
-export const HOME_STEPPER_DATA: HomeStepperDataType = {
- steps: [
- {
- heading: [
- {
- text: msg`Begin with production-grade`,
- fontFamily: 'serif',
- },
- {
- text: msg`building blocks`,
- fontFamily: 'sans',
- },
- ],
- body: {
- text: msg`Compose your CRM and internal apps with a single extensibility toolkit. Data model, layout, and automation.`,
- },
- },
- {
- heading: [
- {
- text: msg`Continue iteration`,
- fontFamily: 'serif',
- },
- {
- text: msg`without friction`,
- fontFamily: 'sans',
- },
- ],
- body: {
- text: msg`Enjoy unlimited customization using the AI coding tools you already love. Adapt your CRM to fit the way your business grows and wins.`,
- },
- },
- {
- heading: [
- {
- text: msg`Stay in control with our`,
- fontFamily: 'serif',
- },
- {
- text: msg`open-source software`,
- fontFamily: 'sans',
- },
- ],
- body: {
- text: msg`Don't get locked into someone else's ecosystem. Twenty's developer experience looks like normal software, with local setup, real data, live testing, and no proprietary tooling.`,
- },
- },
- ],
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx b/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx
index 1d9eab4cbd..fc38182263 100644
--- a/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx
@@ -1,15 +1,13 @@
import { msg } from '@lingui/core/macro';
-import { HELPED_DATA } from '@/app/[locale]/(home)/helped.data';
-import { HERO_COPY, HERO_DATA } from '@/app/[locale]/(home)/hero.data';
-import { HOME_STEPPER_DATA } from '@/app/[locale]/(home)/home-stepper.data';
-import { PROBLEM_DATA } from '@/app/[locale]/(home)/problem.data';
-import { TESTIMONIALS_DATA } from '@/app/[locale]/(home)/testimonials.data';
-import { THREE_CARDS_FEATURE_DATA } from '@/app/[locale]/(home)/three-cards-feature.data';
-import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/(home)/three-cards-illustration.data';
-import { TalkToUsButton } from '@/lib/contact-cal';
-import { FAQ_DATA } from '@/sections/Faq/data';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
+import { HELPED_CARDS } from '@/app/[locale]/(home)/helped.data';
+import { HERO_DATA } from '@/app/[locale]/(home)/hero.data';
+import { Problem, type ProblemPointType } from '@/sections/Problem';
+import { HOME_TESTIMONIALS } from '@/app/[locale]/(home)/testimonials.data';
+import { FEATURE_CARDS } from '@/app/[locale]/(home)/three-cards-feature.data';
+import { ILLUSTRATION_CARDS } from '@/app/[locale]/(home)/three-cards-illustration.data';
+import { TalkToUsButton } from '@/sections/ContactCal';
+import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
+import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
import {
Body,
Eyebrow,
@@ -18,23 +16,19 @@ import {
LinkButton,
} from '@/design-system/components';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
+} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { Faq } from '@/sections/Faq/components';
-import { Helped } from '@/sections/Helped/components';
-import { Hero } from '@/sections/Hero/components';
-import { HomeStepper } from '@/sections/HomeStepper/components';
-import { Menu } from '@/sections/Menu/components';
-import { Problem } from '@/sections/Problem/components';
-import { Testimonials } from '@/sections/Testimonials/components';
-import { ThreeCards } from '@/sections/ThreeCards/components';
+import { Helped } from '@/sections/Helped';
+import { Hero } from '@/sections/Hero';
+import { HomeStepper, type HomeStepperStepType } from '@/sections/HomeStepper';
+import { Menu, MENU_DATA } from '@/sections/Menu';
+import { Testimonials } from '@/sections/Testimonials';
+import { ThreeCards } from '@/sections/ThreeCards';
import { buildFaqPageJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo';
-import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
@@ -106,12 +100,72 @@ export default async function HomePage({ params }: HomePageProps) {
getRouteI18n(params),
fetchCommunityStats(),
]);
- const renderText = createMessageDescriptorRenderer(i18n);
+
+ const PROBLEM_POINTS: ProblemPointType[] = [
+ {
+ heading: (
+
+ {i18n._(msg`The Giant Monolith`)}
+
+ ),
+ body: msg`Proprietary languages, slow deployment cycles, and "black box" logic.`,
+ },
+ {
+ heading: (
+
+ {i18n._(msg`The In-house Burden`)}
+
+ ),
+ body: msg`It's fragile. V1 ships quickly, but maintaining and making changes is a long term burden.`,
+ },
+ ];
+
+ const HOME_STEPPER_STEPS: HomeStepperStepType[] = [
+ {
+ heading: (
+
+
+ {i18n._(msg`Begin with production-grade`)}
+ {' '}
+
+ {i18n._(msg`building blocks`)}
+
+
+ ),
+ body: msg`Compose your CRM and internal apps with a single extensibility toolkit. Data model, layout, and automation.`,
+ },
+ {
+ heading: (
+
+
+ {i18n._(msg`Continue iteration`)}
+ {' '}
+
+ {i18n._(msg`without friction`)}
+
+
+ ),
+ body: msg`Enjoy unlimited customization using the AI coding tools you already love. Adapt your CRM to fit the way your business grows and wins.`,
+ },
+ {
+ heading: (
+
+
+ {i18n._(msg`Stay in control with our`)}
+ {' '}
+
+ {i18n._(msg`open-source software`)}
+
+
+ ),
+ body: msg`Don't get locked into someone else's ecosystem. Twenty's developer experience looks like normal software, with local setup, real data, live testing, and no proprietary tooling.`,
+ },
+ ];
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
<>
-
+ i18n._(d))} />
{/*
* Above-the-fold home hero background texture. Preload warms the
* HTTP cache so it is ready by the time HomeBackgroundHalftone
@@ -119,9 +173,25 @@ export default async function HomePage({ params }: HomePageProps) {
*/}
+
+
+
-
+
- {renderText(msg`Build your Enterprise CRM`)}
+ {i18n._(msg`Build your Enterprise CRM`)}
{' '}
- {renderText(msg`at\u00A0AI\u00A0Speed`)}
+ {i18n._(msg`at\u00A0AI\u00A0Speed`)}
-
+
+ {i18n._(
+ msg`Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves.`,
+ )}
+
-
-
-
-
-
+
-
+
+
+ {i18n._(msg`The Problem.`)}
+
+
- {renderText(msg`A custom CRM gives your org an edge,`)}
+ {i18n._(msg`A custom CRM gives your org an edge,`)}
{' '}
- {renderText(msg`but building one`)}
+ {i18n._(msg`but building one`)}
{' '}
- {renderText(msg`comes with`)}
+ {i18n._(msg`comes with`)}
{' '}
- {renderText(msg`tradeoffs`)}
+ {i18n._(msg`tradeoffs`)}
-
+
-
+
-
+
+
+ {i18n._(msg`Stop settling for trade-offs.`)}
+
+
- {renderText(msg`Assemble, iterate and adapt a robust CRM,`)}
+ {i18n._(msg`Assemble, iterate and adapt a robust CRM,`)}
{' '}
- {renderText(msg`that's quick to flex`)}
+ {i18n._(msg`that's quick to flex`)}
- {THREE_CARDS_ILLUSTRATION_DATA.body && (
-
- )}
+
+ {i18n._(
+ msg`Compose your CRM and internal apps with a single extensibility toolkit.`,
+ )}
+
-
+
-
+
-
+
-
+
+
+ {i18n._(msg`Skip the clunky UX that always comes with custom.`)}
+
+
- {renderText(msg`Make your GTM team happy`)}
+ {i18n._(msg`Make your GTM team happy`)}
- {renderText(msg`with`)}
+ {i18n._(msg`with`)}
{' '}
- {renderText(msg`a CRM they'll love`)}
+ {i18n._(msg`a CRM they'll love`)}
-
+
-
-
+
+
-
+
@@ -292,25 +342,25 @@ export default async function HomePage({ params }: HomePageProps) {
-
+
+
+ {i18n._(msg`Any Questions?`)}
+
+
- {renderText(msg`Stop fighting custom.`)}
+ {i18n._(msg`Stop fighting custom.`)}
- {renderText(msg`Start building, with Twenty`)}
+ {i18n._(msg`Start building, with Twenty`)}
-
+
>
);
diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/problem.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/problem.data.ts
deleted file mode 100644
index 483f80bcaa..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/(home)/problem.data.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { msg } from '@lingui/core/macro';
-import type { ProblemDataType } from '@/sections/Problem/types';
-
-export const PROBLEM_DATA: ProblemDataType = {
- eyebrow: { heading: { text: msg`The Problem.`, fontFamily: 'sans' } },
- points: [
- {
- heading: { text: msg`The Giant Monolith`, fontFamily: 'sans' },
- body: {
- text: msg`Proprietary languages, slow deployment cycles, and "black box" logic.`,
- },
- },
- {
- heading: { text: msg`The In-house Burden`, fontFamily: 'sans' },
- body: {
- text: msg`It's fragile. V1 ships quickly, but maintaining and making changes is a long term burden.`,
- },
- },
- ],
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/testimonials.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/testimonials.data.ts
index ee091bb194..f3f33cd2cf 100644
--- a/packages/twenty-website-new/src/app/[locale]/(home)/testimonials.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/(home)/testimonials.data.ts
@@ -1,42 +1,26 @@
import { msg } from '@lingui/core/macro';
-import type { TestimonialsDataType } from '@/sections/Testimonials/types';
+import type { TestimonialCardType } from '@/sections/Testimonials';
-export const TESTIMONIALS_DATA: TestimonialsDataType = {
- eyebrow: {
- heading: { text: msg`They are the real sales`, fontFamily: 'sans' },
+export const HOME_TESTIMONIALS: TestimonialCardType[] = [
+ {
+ heading: msg`The flexibility is really what made the difference. Our needs evolve very fast. I discover a new need and in two clicks I can address it. That is a real advantage when you are moving quickly.`,
+ author: {
+ name: msg`Olivier Reinaud`,
+ designation: msg`Co-founder at NetZero`,
+ },
},
- testimonials: [
- {
- heading: {
- text: msg`The flexibility is really what made the difference. Our needs evolve very fast. I discover a new need and in two clicks I can address it. That is a real advantage when you are moving quickly.`,
- fontFamily: 'sans',
- },
- author: {
- name: { text: msg`Olivier Reinaud` },
- designation: { text: msg`Co-founder at NetZero` },
- },
+ {
+ heading: msg`We didn't want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind.`,
+ author: {
+ name: msg`Amrendra Pratap Singh`,
+ designation: msg`VP of Engineering at W3villa Technologies`,
},
- {
- heading: {
- text: msg`We didn't want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind.`,
- fontFamily: 'sans',
- },
- author: {
- name: { text: msg`Amrendra Pratap Singh` },
- designation: { text: msg`VP of Engineering at W3villa Technologies` },
- },
+ },
+ {
+ heading: msg`It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other. Twenty made that possible in a way older CRM platforms simply do not.`,
+ author: {
+ name: msg`Justin Beadle`,
+ designation: msg`Director of Digital and Information, Elevate Consulting`,
},
- {
- heading: {
- text: msg`It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other. Twenty made that possible in a way older CRM platforms simply do not.`,
- fontFamily: 'sans',
- },
- author: {
- name: { text: msg`Justin Beadle` },
- designation: {
- text: msg`Director of Digital and Information, Elevate Consulting`,
- },
- },
- },
- ],
-};
+ },
+];
diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-feature.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-feature.data.ts
index 50b2af9e44..a1285562fd 100644
--- a/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-feature.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-feature.data.ts
@@ -1,43 +1,29 @@
import { msg } from '@lingui/core/macro';
-import type { ThreeCardsFeatureCardsDataType } from '@/sections/ThreeCards/types';
+import type { ThreeCardsFeatureCardType } from '@/sections/ThreeCards';
-export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
- eyebrow: {
- heading: {
- text: msg`Skip the clunky UX that always comes with custom.`,
- fontFamily: 'sans',
- },
+export const FEATURE_CARDS: ThreeCardsFeatureCardType[] = [
+ {
+ heading: msg`Familiar, modern interface`,
+ body: msg`Twenty makes it simple. It's clean, intuitive, and built to feel like Notion.`,
+ backgroundImageSrc:
+ '/images/home/three-cards-feature/familiar-interface-gradient.webp',
+ icon: 'users-group',
+ illustration: 'familiar-interface',
},
- featureCards: [
- {
- heading: { text: msg`Familiar, modern interface`, fontFamily: 'sans' },
- body: {
- text: msg`Twenty makes it simple. It's clean, intuitive, and built to feel like Notion.`,
- },
- backgroundImageSrc:
- '/images/home/three-cards-feature/familiar-interface-gradient.webp',
- icon: 'users-group',
- illustration: 'familiar-interface',
- },
- {
- heading: { text: msg`Live data and AI built`, fontFamily: 'sans' },
- body: {
- text: msg`Everything updates in real time, with AI chat always ready to help you move faster.`,
- },
- backgroundImageSrc:
- '/images/home/three-cards-feature/live-data-gradient.webp',
- icon: 'live-data',
- illustration: 'live-data',
- },
- {
- heading: { text: msg`Fast path to action`, fontFamily: 'sans' },
- body: {
- text: msg`Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.`,
- },
- backgroundImageSrc:
- '/images/home/three-cards-feature/fast-path-gradient.webp',
- icon: 'fast-path',
- illustration: 'fast-path',
- },
- ],
-};
+ {
+ heading: msg`Live data and AI built`,
+ body: msg`Everything updates in real time, with AI chat always ready to help you move faster.`,
+ backgroundImageSrc:
+ '/images/home/three-cards-feature/live-data-gradient.webp',
+ icon: 'live-data',
+ illustration: 'live-data',
+ },
+ {
+ heading: msg`Fast path to action`,
+ body: msg`Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.`,
+ backgroundImageSrc:
+ '/images/home/three-cards-feature/fast-path-gradient.webp',
+ icon: 'fast-path',
+ illustration: 'fast-path',
+ },
+];
diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-illustration.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-illustration.data.ts
index aeba6f3314..0bed80b871 100644
--- a/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-illustration.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/(home)/three-cards-illustration.data.ts
@@ -1,52 +1,38 @@
import { msg } from '@lingui/core/macro';
-import type { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
+import type { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards';
-export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
- eyebrow: {
- heading: { text: msg`Stop settling for trade-offs.`, fontFamily: 'sans' },
+export const ILLUSTRATION_CARDS: ThreeCardsIllustrationCardType[] = [
+ {
+ heading: msg`Production grade quality`,
+ body: msg`W3villa used Twenty as a production-grade framework for the data model, permissions, authentication, and workflow engine they would otherwise have rebuilt themselves.`,
+ benefits: undefined,
+ attribution: {
+ role: msg`VP of Engineering`,
+ company: msg`W3villa Technologies`,
+ },
+ illustration: 'diamond',
+ caseStudySlug: 'w3villa',
},
- body: {
- text: msg`Compose your CRM and internal apps with a single extensibility toolkit.`,
+ {
+ heading: msg`AI for rapid iterations`,
+ body: msg`Alternative Partners used agentic AI to compress what would typically be weeks of Salesforce migration work into something a single person could oversee.`,
+ benefits: undefined,
+ attribution: {
+ role: msg`Principal and Founder`,
+ company: msg`Alternative Partners`,
+ },
+ illustration: 'flash',
+ caseStudySlug: 'alternative-partners',
},
- illustrationCards: [
- {
- heading: { text: msg`Production grade quality`, fontFamily: 'sans' },
- body: {
- text: msg`W3villa used Twenty as a production-grade framework for the data model, permissions, authentication, and workflow engine they would otherwise have rebuilt themselves.`,
- },
- benefits: undefined,
- attribution: {
- role: { text: msg`VP of Engineering` },
- company: { text: msg`W3villa Technologies` },
- },
- illustration: 'diamond',
- caseStudySlug: 'w3villa',
+ {
+ heading: msg`Control without drag`,
+ body: msg`AC&T moved to a self-hosted Twenty instance with no vendor risk, no forced migration, and CRM costs reduced by more than 90%.`,
+ benefits: undefined,
+ attribution: {
+ role: msg`CRM Engineer`,
+ company: msg`AC&T Education Migration`,
},
- {
- heading: { text: msg`AI for rapid iterations`, fontFamily: 'sans' },
- body: {
- text: msg`Alternative Partners used agentic AI to compress what would typically be weeks of Salesforce migration work into something a single person could oversee.`,
- },
- benefits: undefined,
- attribution: {
- role: { text: msg`Principal and Founder` },
- company: { text: msg`Alternative Partners` },
- },
- illustration: 'flash',
- caseStudySlug: 'alternative-partners',
- },
- {
- heading: { text: msg`Control without drag`, fontFamily: 'sans' },
- body: {
- text: msg`AC&T moved to a self-hosted Twenty instance with no vendor risk, no forced migration, and CRM costs reduced by more than 90%.`,
- },
- benefits: undefined,
- attribution: {
- role: { text: msg`CRM Engineer` },
- company: { text: msg`AC&T Education Migration` },
- },
- illustration: 'lock',
- caseStudySlug: 'act-education',
- },
- ],
-};
+ illustration: 'lock',
+ caseStudySlug: 'act-education',
+ },
+];
diff --git a/packages/twenty-website-new/src/app/[locale]/articles/[slug]/page.tsx b/packages/twenty-website-new/src/app/[locale]/articles/[slug]/page.tsx
index da2de3b534..86dbd30539 100644
--- a/packages/twenty-website-new/src/app/[locale]/articles/[slug]/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/articles/[slug]/page.tsx
@@ -4,14 +4,13 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { getPublishedArticle, getPublishedArticles } from '@/lib/articles';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { type LocaleRouteParams } from '@/lib/i18n/get-route-i18n';
-import { localeToUrlSegment } from '@/lib/i18n/website-locale-segments';
+import { type LocaleRouteParams } from '@/lib/i18n/utils/get-route-i18n';
+import { localeToUrlSegment } from '@/lib/i18n/utils/website-locale-segments';
import { resolveLocaleParam } from '@/lib/i18n';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { buildArticleJsonLd, buildPageMetadata, JsonLd } from '@/lib/seo';
-import { Articles } from '@/sections/Articles/components';
-import { Menu } from '@/sections/Menu/components';
-import { MENU_DATA } from '@/sections/Menu/data';
+import { Articles } from '@/sections/Articles';
+import { Menu, MENU_DATA } from '@/sections/Menu';
import { theme } from '@/theme';
type ArticleSlugParams = LocaleRouteParams & { slug: string };
diff --git a/packages/twenty-website-new/src/app/[locale]/articles/page.tsx b/packages/twenty-website-new/src/app/[locale]/articles/page.tsx
index 3064a3de78..486d8d29b4 100644
--- a/packages/twenty-website-new/src/app/[locale]/articles/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/articles/page.tsx
@@ -1,32 +1,31 @@
+import { msg } from '@lingui/core/macro';
import { notFound } from 'next/navigation';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { HeadingPart } from '@/design-system/components';
import { getPublishedArticles } from '@/lib/articles';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
+import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
+import { resolveLocaleParam } from '@/lib/i18n';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
-import { resolveLocaleParam } from '@/lib/i18n';
-import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
+} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
-import { buildArticleListJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo';
-import { Articles } from '@/sections/Articles/components';
-import { Hero } from '@/sections/Hero/components';
-import { Menu } from '@/sections/Menu/components';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { TrustedBy } from '@/sections/TrustedBy/components';
-import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
+import {
+ buildArticleListJsonLd,
+ buildBreadcrumbListJsonLd,
+ buildRouteMetadata,
+ JsonLd,
+} from '@/lib/seo';
+import { Articles } from '@/sections/Articles';
+import { Hero } from '@/sections/Hero';
+import { Menu, MENU_DATA } from '@/sections/Menu';
+import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
import { css } from '@linaria/core';
export const generateMetadata = buildRouteMetadata('articles');
-const ARTICLES_HERO_BODY = {
- text: 'Ideas from the team building Twenty on open source CRM, GTM systems, and building software that lasts.',
-};
-
const ARTICLES_TOP_BACKGROUND_COLOR = '#F4F4F4';
const pageRevealClassName = css`
@@ -71,12 +70,20 @@ export default async function ArticlesPage({ params }: ArticlesPageProps) {
getRouteI18n(params),
fetchCommunityStats(),
]);
- const renderText = createMessageDescriptorRenderer(i18n);
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
const posts = getPublishedArticles();
return (
<>
+
{posts.length > 0 ? (
) : null}
@@ -93,29 +100,25 @@ export default async function ArticlesPage({ params }: ArticlesPageProps) {
-
+
Ideas on
open-source {' '}
CRM
-
+
+ Ideas from the team building Twenty on open source CRM, GTM systems,
+ and building software that lasts.
+
-
-
-
-
+ separator={i18n._(msg`trusted by`)}
+ logos={TRUSTED_BY_LOGOS}
+ clientCount={i18n._(msg`+10k others`)}
+ />
diff --git a/packages/twenty-website-new/src/app/[locale]/customers/9dots/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/9dots/page.tsx
index c2ca86b168..35c916ccfa 100644
--- a/packages/twenty-website-new/src/app/[locale]/customers/9dots/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/customers/9dots/page.tsx
@@ -1,119 +1,23 @@
import { msg } from '@lingui/core/macro';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
-import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
-import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
+import { type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
-import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { CaseStudy } from '@/sections/CaseStudy/components';
-import { Menu } from '@/sections/Menu/components';
-import { theme } from '@/theme';
+} from '@/lib/i18n/utils/get-route-i18n';
import { buildLocalizedMetadata } from '@/lib/seo';
+import { Heading, HeadingPart } from '@/design-system/components';
+import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1733244766159-f58f4184fd38?w=1600&q=80';
-const CASE_STUDY: CaseStudyData = {
- meta: {
- title: msg`Homeseller, WhatsApp, and a CRM built around the business | Nine Dots & Twenty`,
- description: msg`How Nine Dots Ventures rebuilt a Singapore real estate agency on Twenty with APIs, n8n, Grafana, and AI on top of 2,000+ WhatsApp messages a day.`,
- },
- hero: {
- readingTime: '9 min',
- title: [
- {
- text: msg`A real estate agency on WhatsApp`,
- fontFamily: 'serif',
- },
- { text: msg`built a`, fontFamily: 'serif' },
- { text: msg`CRM`, fontFamily: 'sans', newLine: true },
- { text: msg`around it`, fontFamily: 'serif' },
- ],
- author: 'Mike Babiy & Azmat Parveen',
- authorAvatarSrc: '/images/partner/testimonials/mike-babiy.png',
- authorRole: msg`Founder, Nine Dots Ventures`,
- clientIcon: 'nine-dots',
- heroImageSrc: PLACEHOLDER_HERO,
- industry: msg`Real Estate`,
- kpis: [
- { value: msg`150 hrs`, label: msg`Saved / month` },
- { value: msg`2,000+`, label: msg`Daily messages` },
- { value: msg`Q1 2026`, label: msg`Record quarter` },
- ],
- quote: {
- text: msg`Twenty lets us build a CRM around the business and not the business around the CRM.`,
- author: 'Mike Babiy',
- role: msg`Founder, Nine Dots Ventures`,
- },
- },
- sections: [
- {
- type: 'text',
- eyebrow: msg`Homeseller`,
- heading: [
- {
- text: msg`When the channel is`,
- fontFamily: 'serif',
- },
- { text: msg`the business`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`Homeseller is a high-volume real estate agency in Singapore, founded by one of the country's top-performing property agents. The whole operation runs on WhatsApp: no email, no calendars, just group chats, thousands of them, with clients, agents, and leads together.`,
- msg`That works until you need to understand the business underneath. Which deals are stuck? Where are leads coming from? What is the close rate? With spreadsheets and a legacy custom CRM that could not keep up, those questions were nearly impossible to answer.`,
- msg`Mike and Azmat from Nine Dots stepped in to fix that, not by changing how Homeseller works, but by building a system that finally fit around it.`,
- ],
- callout:
- '"Twenty lets us build a CRM around the business and not the business around the CRM." - Mike Babiy, Founder, Nine Dots Ventures',
- },
- {
- type: 'text',
- eyebrow: msg`Architecture`,
- heading: [
- { text: msg`The CRM as a`, fontFamily: 'serif' },
- { text: msg`control hub`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`Nine Dots rebuilt Homeseller's operations on Twenty, with a custom data model shaped around their sales flow. Because Twenty is open and everything is accessible via API, they connected it to what the business actually needed: n8n for automated workflows (in-app workflows were not available at that time), Grafana for live dashboards fed from Twenty, and a custom AI layer to parse and extract structured insights from more than 2,000 WhatsApp messages a day.`,
- msg`Homeseller kept their habits. WhatsApp stayed WhatsApp. What changed is that everything flowing through those conversations now lands in a structured system, tracked, classified, and visible in real time.`,
- ],
- callout:
- '"Twenty is the heart of the system. Everything branches from it." - Azmat Parveen, Nine Dots Ventures',
- },
- {
- type: 'text',
- eyebrow: msg`The result`,
- heading: [
- { text: msg`150 hours`, fontFamily: 'sans' },
- {
- text: msg`saved every month`,
- fontFamily: 'serif',
- },
- ],
- paragraphs: [
- msg`About 150 hours per month saved in manual operations. Real-time metrics for the business owner. Growth readiness without adding operational headcount. A team that can answer questions that used to take days to piece together.`,
- msg`The full rollout landed in July 2025. Since then, Nine Dots built a Smart Assistant on top of the system, nudging agents with tasks, reminders, and on-demand market analysis. Some agents never open Twenty directly, yet they are powered by it, outperforming peers on manual processes alone. By Q1 2026, Homeseller had recorded its best sales quarter ever.`,
- ],
- },
- ],
- tableOfContents: [
- msg`When the channel is the business`,
- msg`The CRM as a control hub`,
- msg`The result`,
- ],
- catalogCard: {
- summary: msg`Nine Dots put Twenty at the center of Homeseller's stack with APIs, automation, and AI on top of WhatsApp-heavy operations.`,
- date: msg`Jul 2025`,
- },
-};
+const META_TITLE = msg`Homeseller, WhatsApp, and a CRM built around the business | Nine Dots & Twenty`;
+const META_DESCRIPTION = msg`How Nine Dots Ventures rebuilt a Singapore real estate agency on Twenty with APIs, n8n, Grafana, and AI on top of 2,000+ WhatsApp messages a day.`;
export const generateMetadata = buildLocalizedMetadata({
path: '/customers/9dots',
- title: CASE_STUDY.meta.title,
- description: CASE_STUDY.meta.description,
+ title: META_TITLE,
+ description: META_DESCRIPTION,
});
type CaseStudyPageProps = {
@@ -123,69 +27,114 @@ type CaseStudyPageProps = {
export default async function NineDotsCaseStudyPage({
params,
}: CaseStudyPageProps) {
- const [i18n, stats] = await Promise.all([
- getRouteI18n(params),
- fetchCommunityStats(),
- ]);
- const renderText = createMessageDescriptorRenderer(i18n);
- const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
- const palette = getCaseStudyPalette('/customers/9dots');
+ const i18n = await getRouteI18n(params);
- let storySectionIndex = 0;
- const sectionBlocks = CASE_STUDY.sections.map((block, index) => {
- if (block.type === 'text') {
- const sectionId = `case-study-section-${storySectionIndex}`;
- storySectionIndex += 1;
- return (
-
- );
- }
- return (
-
- );
- });
+ const caseStudy: CaseStudyData = {
+ meta: {
+ title: META_TITLE,
+ description: META_DESCRIPTION,
+ },
+ hero: {
+ readingTime: '9 min',
+ title: (
+
+
+ {i18n._(msg`A real estate agency on WhatsApp`)}
+ {' '}
+ {i18n._(msg`built a`)}
+
+ {i18n._(msg`CRM`)} {' '}
+ {i18n._(msg`around it`)}
+
+ ),
+ author: 'Mike Babiy & Azmat Parveen',
+ authorAvatarSrc: '/images/partner/testimonials/mike-babiy.webp',
+ authorRole: msg`Founder, Nine Dots Ventures`,
+ clientIcon: 'nine-dots',
+ heroImageSrc: PLACEHOLDER_HERO,
+ industry: msg`Real Estate`,
+ kpis: [
+ { value: msg`150 hrs`, label: msg`Saved / month` },
+ { value: msg`2,000+`, label: msg`Daily messages` },
+ { value: msg`Q1 2026`, label: msg`Record quarter` },
+ ],
+ quote: {
+ text: msg`Twenty lets us build a CRM around the business and not the business around the CRM.`,
+ author: 'Mike Babiy',
+ role: msg`Founder, Nine Dots Ventures`,
+ },
+ },
+ sections: [
+ {
+ type: 'text',
+ eyebrow: msg`Homeseller`,
+ heading: (
+
+
+ {i18n._(msg`When the channel is`)}
+ {' '}
+
+ {i18n._(msg`the business`)}
+
+
+ ),
+ paragraphs: [
+ msg`Homeseller is a high-volume real estate agency in Singapore, founded by one of the country's top-performing property agents. The whole operation runs on WhatsApp: no email, no calendars, just group chats, thousands of them, with clients, agents, and leads together.`,
+ msg`That works until you need to understand the business underneath. Which deals are stuck? Where are leads coming from? What is the close rate? With spreadsheets and a legacy custom CRM that could not keep up, those questions were nearly impossible to answer.`,
+ msg`Mike and Azmat from Nine Dots stepped in to fix that, not by changing how Homeseller works, but by building a system that finally fit around it.`,
+ ],
+ callout:
+ '"Twenty lets us build a CRM around the business and not the business around the CRM." - Mike Babiy, Founder, Nine Dots Ventures',
+ },
+ {
+ type: 'text',
+ eyebrow: msg`Architecture`,
+ heading: (
+
+
+ {i18n._(msg`The CRM as a`)}
+ {' '}
+
+ {i18n._(msg`control hub`)}
+
+
+ ),
+ paragraphs: [
+ msg`Nine Dots rebuilt Homeseller's operations on Twenty, with a custom data model shaped around their sales flow. Because Twenty is open and everything is accessible via API, they connected it to what the business actually needed: n8n for automated workflows (in-app workflows were not available at that time), Grafana for live dashboards fed from Twenty, and a custom AI layer to parse and extract structured insights from more than 2,000 WhatsApp messages a day.`,
+ msg`Homeseller kept their habits. WhatsApp stayed WhatsApp. What changed is that everything flowing through those conversations now lands in a structured system, tracked, classified, and visible in real time.`,
+ ],
+ callout:
+ '"Twenty is the heart of the system. Everything branches from it." - Azmat Parveen, Nine Dots Ventures',
+ },
+ {
+ type: 'text',
+ eyebrow: msg`The result`,
+ heading: (
+
+
+ {i18n._(msg`150 hours`)}
+ {' '}
+
+ {i18n._(msg`saved every month`)}
+
+
+ ),
+ paragraphs: [
+ msg`About 150 hours per month saved in manual operations. Real-time metrics for the business owner. Growth readiness without adding operational headcount. A team that can answer questions that used to take days to piece together.`,
+ msg`The full rollout landed in July 2025. Since then, Nine Dots built a Smart Assistant on top of the system, nudging agents with tasks, reminders, and on-demand market analysis. Some agents never open Twenty directly, yet they are powered by it, outperforming peers on manual processes alone. By Q1 2026, Homeseller had recorded its best sales quarter ever.`,
+ ],
+ },
+ ],
+ tableOfContents: [
+ msg`When the channel is the business`,
+ msg`The CRM as a control hub`,
+ msg`The result`,
+ ],
+ catalogCard: {
+ summary: msg`Nine Dots put Twenty at the center of Homeseller's stack with APIs, automation, and AI on top of WhatsApp-heavy operations.`,
+ date: msg`Jul 2025`,
+ },
+ };
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
- {sectionBlocks}
-
-
-
- >
- );
+ return ;
}
diff --git a/packages/twenty-website-new/src/app/[locale]/customers/_components/CaseStudyPageLayout.tsx b/packages/twenty-website-new/src/app/[locale]/customers/_components/CaseStudyPageLayout.tsx
new file mode 100644
index 0000000000..2a0ae8bf80
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/customers/_components/CaseStudyPageLayout.tsx
@@ -0,0 +1,77 @@
+import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
+import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
+import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
+import { CaseStudy } from '@/sections/CaseStudy';
+import { Menu, MENU_DATA } from '@/sections/Menu';
+import { theme } from '@/theme';
+
+import { CustomersCaseStudySignoff } from './CustomersCaseStudySignoff';
+
+type CaseStudyPageLayoutProps = {
+ caseStudy: CaseStudyData;
+ path: string;
+};
+
+export async function CaseStudyPageLayout({
+ caseStudy,
+ path,
+}: CaseStudyPageLayoutProps) {
+ const stats = await fetchCommunityStats();
+ const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
+ const palette = getCaseStudyPalette(path);
+
+ let storySectionIndex = 0;
+ const sectionBlocks = caseStudy.sections.map((block, index) => {
+ if (block.type === 'text') {
+ const sectionId = `case-study-section-${storySectionIndex}`;
+ storySectionIndex += 1;
+ return (
+
+ );
+ }
+ return (
+
+ );
+ });
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {sectionBlocks}
+
+
+
+ >
+ );
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/customers/_components/CustomersCaseStudySignoff.tsx b/packages/twenty-website-new/src/app/[locale]/customers/_components/CustomersCaseStudySignoff.tsx
index f23e2c28bd..1783a952f6 100644
--- a/packages/twenty-website-new/src/app/[locale]/customers/_components/CustomersCaseStudySignoff.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/customers/_components/CustomersCaseStudySignoff.tsx
@@ -1,43 +1,29 @@
import { msg } from '@lingui/core/macro';
-import { TalkToUsButton } from '@/lib/contact-cal';
+import { TalkToUsButton } from '@/sections/ContactCal';
import { HeadingPart, LinkButton } from '@/design-system/components';
+import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
import { Pages } from '@/lib/pages';
-import { Signoff } from '@/sections/Signoff/components';
-import { theme } from '@/theme';
-import type { MessageDescriptor } from '@lingui/core';
+import { Signoff } from '@/sections/Signoff';
-const SIGNOFF_BODY = {
- text: msg`Join the teams that chose to own their CRM.\nStart building with Twenty today.`,
-};
-
-type CustomersCaseStudySignoffProps = {
- renderText: (descriptor: MessageDescriptor) => string;
-};
-
-export function CustomersCaseStudySignoff({
- renderText,
-}: CustomersCaseStudySignoffProps) {
+export function CustomersCaseStudySignoff() {
+ const i18n = getServerI18n();
return (
-
+
- {renderText(msg`Ready to grow\nwith Twenty?`)}
+ {i18n._(msg`Ready to grow\nwith Twenty?`)}
-
+
+ {i18n._(
+ msg`Join the teams that chose to own their CRM.\nStart building with Twenty today.`,
+ )}
+
{
- if (block.type === 'text') {
- const sectionId = `case-study-section-${storySectionIndex}`;
- storySectionIndex += 1;
- return (
-
- );
- }
- return (
-
- );
- });
+ const caseStudy: CaseStudyData = {
+ meta: {
+ title: META_TITLE,
+ description: META_DESCRIPTION,
+ },
+ hero: {
+ readingTime: '7 min',
+ title: (
+
+
+ {i18n._(msg`A CRM they`)}
+
+
+
+ {i18n._(msg`actually own`)}
+
+
+ ),
+ author: 'Joseph Chiang',
+ authorAvatarSrc: '/images/partner/testimonials/joseph-chiang.webp',
+ authorRole: msg`CRM Engineer, AC&T Education Migration`,
+ clientIcon: 'act-education',
+ heroImageSrc: PLACEHOLDER_HERO,
+ industry: msg`Education`,
+ kpis: [{ value: msg`90%+`, label: msg`Lower CRM cost` }],
+ },
+ sections: [
+ {
+ type: 'text',
+ eyebrow: msg`AC&T Education Migration`,
+ heading: (
+
+
+ {i18n._(msg`When the vendor`)}
+ {' '}
+
+ {i18n._(msg`pulled the plug`)}
+
+
+ ),
+ paragraphs: [
+ msg`AC&T Education Migration (actimmi.com) is an education agency in Australia. They help international students with applications to education providers and visas. They had been on a previous CRM until the vendor shut the system down, leaving nothing but a CSV export.`,
+ msg`Whatever came next had to be something they could own.`,
+ ],
+ callout:
+ '"They did not want to learn someone else\'s system. They wanted to keep working the way they already did and make it smoother." - Joseph Chiang, CRM Engineer, AC&T Education Migration',
+ },
+ {
+ type: 'text',
+ eyebrow: msg`Implementation`,
+ heading: (
+
+
+ {i18n._(msg`No more renting someone else's`)}
+ {' '}
+
+ {i18n._(msg`structure`)}
+
+
+ ),
+ paragraphs: [
+ msg`They evaluated Salesforce, Zoho, Pipedrive, and SuiteCRM. Each came with the same tradeoffs: too expensive, too rigid, or too generic, and none fixed the underlying problem. They were still renting a structure they did not control.`,
+ msg`Flycoder, a full-stack development partner, helped them set up Twenty as a self-hosted instance shaped around how AC&T actually operates. The data model centers on students, not a generic contact-and-deal pipeline. Statuses update automatically: a workflow runs nightly to keep enrollment records current. Automated email reminders cover important dates. Adding a new record takes under a minute.`,
+ msg`The result is a system that fits how AC&T already worked, instead of the other way around.`,
+ ],
+ },
+ {
+ type: 'text',
+ eyebrow: msg`Control`,
+ heading: (
+
+
+ {i18n._(msg`Control without`)}
+ {' '}
+
+ {i18n._(msg`the overhead`)}
+
+
+ ),
+ paragraphs: [
+ msg`Self-hosted means AC&T carries no vendor risk: no pricing model that can change, no platform that can disappear, no forced migration. The system is theirs.`,
+ msg`Because everything is built on Twenty's open foundation, Flycoder could wire the exact logic AC&T needed without fighting the platform.`,
+ ],
+ },
+ {
+ type: 'text',
+ eyebrow: msg`The result`,
+ heading: (
+
+
+ {i18n._(msg`Costs down more than`)}
+ {' '}
+ {i18n._(msg`90%`)}
+
+ ),
+ paragraphs: [
+ msg`CRM costs dropped by more than 90%. Manual overhead tied to the old system is gone. For the first time, AC&T has a CRM they will not lose again.`,
+ msg`They did not just replace a tool. They took back ownership of how their business runs.`,
+ ],
+ },
+ ],
+ tableOfContents: [
+ msg`When the vendor pulled the plug`,
+ msg`No more renting someone else's structure`,
+ msg`Control without the overhead`,
+ msg`The result`,
+ ],
+ catalogCard: {
+ summary: msg`AC&T and Flycoder moved from a dead vendor export to self-hosted Twenty, with over 90% lower CRM cost and full control.`,
+ date: msg`2025`,
+ },
+ };
return (
- <>
-
-
-
-
-
-
-
-
-
-
-
- {sectionBlocks}
-
-
-
- >
+
);
}
diff --git a/packages/twenty-website-new/src/app/[locale]/customers/alternative-partners/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/alternative-partners/page.tsx
index 0230c936ac..5b9ecc9b89 100644
--- a/packages/twenty-website-new/src/app/[locale]/customers/alternative-partners/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/customers/alternative-partners/page.tsx
@@ -1,84 +1,23 @@
import { msg } from '@lingui/core/macro';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
-import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
-import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
+import { Heading, HeadingPart } from '@/design-system/components';
+import type { CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
-import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { CaseStudy } from '@/sections/CaseStudy/components';
-import { Menu } from '@/sections/Menu/components';
-import { theme } from '@/theme';
+} from '@/lib/i18n/utils/get-route-i18n';
import { buildLocalizedMetadata } from '@/lib/seo';
+import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
const PLACEHOLDER_HERO =
- 'https://images.unsplash.com/photo-1702047149248-a6049168d2a8?w=1600&q=80';
+ 'https://images.unsplash.com/photo-1702047149248-a6049168a2a8?w=1600&q=80';
-const CASE_STUDY: CaseStudyData = {
- meta: {
- title: msg`From Salesforce to self-hosted Twenty, powered by AI | Alternative Partners`,
- description: msg`How Alternative Partners migrated from Salesforce to self-hosted Twenty using agentic AI in the implementation loop: fast migration, durable ownership.`,
- },
- hero: {
- readingTime: '7 min',
- title: [
- { text: msg`From Salesforce to`, fontFamily: 'serif' },
- { text: msg`self-hosted Twenty`, fontFamily: 'sans', newLine: true },
- ],
- author: 'Benjamin Reynolds',
- authorAvatarSrc: '/images/partner/testimonials/benjamin-reynolds.webp',
- authorRole: msg`Principal and Founder, Alternative Partners`,
- clientIcon: 'alternative-partners',
- heroImageSrc: PLACEHOLDER_HERO,
- industry: msg`Consulting`,
- kpis: [
- { value: msg`AI-assisted`, label: msg`Salesforce migration` },
- { value: msg`Self-hosted`, label: msg`Full ownership` },
- ],
- },
- sections: [
- {
- type: 'text',
- eyebrow: msg`Alternative Partners`,
- heading: [
- { text: msg`AI in the`, fontFamily: 'serif' },
- { text: msg`migration workflow`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`Alternative Partners is a consulting firm that moved from Salesforce to a self-hosted Twenty instance. Benjamin Reynolds led the migration. He had already become a Twenty expert implementing Twenty for one of Twenty's first cloud customers.`,
- msg`His approach was unconventional. Instead of mapping fields manually, scripting transforms, and validating data step by step, he handed the job to agentic AI tools with a brief: where the data lives, the GitHub repo for the target platform, and the Railway deployment. Start, and only return if something breaks beyond a 70% confidence fix.`,
- msg`It worked. This is AI-assisted iteration in practice: not AI as a product feature, but as part of implementation work, compressing what would typically be weeks into something one person can oversee without being the bottleneck.`,
- ],
- },
- {
- type: 'text',
- eyebrow: msg`Ownership`,
- heading: [
- { text: msg`Self-hosted`, fontFamily: 'serif' },
- { text: msg`means control`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`The self-hosted setup means Alternative Partners owns the full stack: no vendor access to their data, no dependency on a SaaS pricing model, full control over how the system evolves. The migration was fast because of AI; the result is durable because the stack is open source.`,
- ],
- },
- ],
- tableOfContents: [
- msg`AI in the migration workflow`,
- msg`Self-hosted means control`,
- ],
- catalogCard: {
- summary: msg`Alternative Partners replaced Salesforce with self-hosted Twenty, using agentic AI to compress migration work.`,
- date: msg`2025`,
- },
-};
+const META_TITLE = msg`From Salesforce to self-hosted Twenty, powered by AI | Alternative Partners`;
+const META_DESCRIPTION = msg`How Alternative Partners migrated from Salesforce to self-hosted Twenty using agentic AI in the implementation loop: fast migration, durable ownership.`;
export const generateMetadata = buildLocalizedMetadata({
path: '/customers/alternative-partners',
- title: CASE_STUDY.meta.title,
- description: CASE_STUDY.meta.description,
+ title: META_TITLE,
+ description: META_DESCRIPTION,
});
type CaseStudyPageProps = {
@@ -88,69 +27,89 @@ type CaseStudyPageProps = {
export default async function AlternativePartnersCaseStudyPage({
params,
}: CaseStudyPageProps) {
- const [i18n, stats] = await Promise.all([
- getRouteI18n(params),
- fetchCommunityStats(),
- ]);
- const renderText = createMessageDescriptorRenderer(i18n);
- const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
- const palette = getCaseStudyPalette('/customers/alternative-partners');
+ const i18n = await getRouteI18n(params);
- let storySectionIndex = 0;
- const sectionBlocks = CASE_STUDY.sections.map((block, index) => {
- if (block.type === 'text') {
- const sectionId = `case-study-section-${storySectionIndex}`;
- storySectionIndex += 1;
- return (
-
- );
- }
- return (
-
- );
- });
+ const caseStudy: CaseStudyData = {
+ meta: {
+ title: META_TITLE,
+ description: META_DESCRIPTION,
+ },
+ hero: {
+ readingTime: '7 min',
+ title: (
+
+
+ {i18n._(msg`From Salesforce to`)}
+
+
+
+ {i18n._(msg`self-hosted Twenty`)}
+
+
+ ),
+ author: 'Benjamin Reynolds',
+ authorAvatarSrc: '/images/partner/testimonials/benjamin-reynolds.webp',
+ authorRole: msg`Principal and Founder, Alternative Partners`,
+ clientIcon: 'alternative-partners',
+ heroImageSrc: PLACEHOLDER_HERO,
+ industry: msg`Consulting`,
+ kpis: [
+ { value: msg`AI-assisted`, label: msg`Salesforce migration` },
+ { value: msg`Self-hosted`, label: msg`Full ownership` },
+ ],
+ },
+ sections: [
+ {
+ type: 'text',
+ eyebrow: msg`Alternative Partners`,
+ heading: (
+
+
+ {i18n._(msg`AI in the`)}
+ {' '}
+
+ {i18n._(msg`migration workflow`)}
+
+
+ ),
+ paragraphs: [
+ msg`Alternative Partners is a consulting firm that moved from Salesforce to a self-hosted Twenty instance. Benjamin Reynolds led the migration. He had already become a Twenty expert implementing Twenty for one of Twenty's first cloud customers.`,
+ msg`His approach was unconventional. Instead of mapping fields manually, scripting transforms, and validating data step by step, he handed the job to agentic AI tools with a brief: where the data lives, the GitHub repo for the target platform, and the Railway deployment. Start, and only return if something breaks beyond a 70% confidence fix.`,
+ msg`It worked. This is AI-assisted iteration in practice: not AI as a product feature, but as part of implementation work, compressing what would typically be weeks into something one person can oversee without being the bottleneck.`,
+ ],
+ },
+ {
+ type: 'text',
+ eyebrow: msg`Ownership`,
+ heading: (
+
+
+ {i18n._(msg`Self-hosted`)}
+ {' '}
+
+ {i18n._(msg`means control`)}
+
+
+ ),
+ paragraphs: [
+ msg`The self-hosted setup means Alternative Partners owns the full stack: no vendor access to their data, no dependency on a SaaS pricing model, full control over how the system evolves. The migration was fast because of AI; the result is durable because the stack is open source.`,
+ ],
+ },
+ ],
+ tableOfContents: [
+ msg`AI in the migration workflow`,
+ msg`Self-hosted means control`,
+ ],
+ catalogCard: {
+ summary: msg`Alternative Partners replaced Salesforce with self-hosted Twenty, using agentic AI to compress migration work.`,
+ date: msg`2025`,
+ },
+ };
return (
- <>
-
-
-
-
-
-
-
-
-
-
-
- {sectionBlocks}
-
-
-
- >
+
);
}
diff --git a/packages/twenty-website-new/src/app/[locale]/customers/elevate-consulting/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/elevate-consulting/page.tsx
index 842c4ac0d7..e9213f44b1 100644
--- a/packages/twenty-website-new/src/app/[locale]/customers/elevate-consulting/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/customers/elevate-consulting/page.tsx
@@ -1,127 +1,23 @@
import { msg } from '@lingui/core/macro';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
-import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
-import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
+import { type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
-import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { CaseStudy } from '@/sections/CaseStudy/components';
-import { Menu } from '@/sections/Menu/components';
-import { theme } from '@/theme';
+} from '@/lib/i18n/utils/get-route-i18n';
import { buildLocalizedMetadata } from '@/lib/seo';
+import { Heading, HeadingPart } from '@/design-system/components';
+import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1758873269035-aae0e1fd3422?w=1600&q=80';
-const CASE_STUDY: CaseStudyData = {
- meta: {
- title: msg`Twenty as the API backbone of a go-to-market stack | Elevate Consulting`,
- description: msg`How Elevate Consulting moved off documents and spreadsheets to Twenty as the API-connected CRM at the center of their stack.`,
- },
- hero: {
- readingTime: '8 min',
- title: [
- { text: msg`Twenty as the`, fontFamily: 'serif' },
- { text: msg`API backbone`, fontFamily: 'sans', newLine: true },
- {
- text: msg`of a go-to-market stack`,
- fontFamily: 'serif',
- },
- ],
- author: 'Justin Beadle',
- authorRole: msg`Director of Digital and Information, Elevate Consulting`,
- clientIcon: 'elevate-consulting',
- heroImageSrc: PLACEHOLDER_HERO,
- industry: msg`Management Consulting`,
- kpis: [
- { value: msg`1 click`, label: msg`Proposal automation` },
- { value: msg`4 tools`, label: msg`Connected via API` },
- { value: msg`API-first`, label: msg`Tool integration` },
- ],
- quote: {
- text: msg`It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other.`,
- author: 'Justin Beadle',
- role: msg`Director of Digital and Information, Elevate Consulting`,
- },
- },
- sections: [
- {
- type: 'text',
- eyebrow: msg`The situation`,
- heading: [
- { text: msg`From documents to`, fontFamily: 'serif' },
- { text: msg`open APIs`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`Elevate Consulting is a management consultancy based in Canada. When Justin Beadle, Director of Digital and Information, joined, the company ran entirely on Word documents, Excel spreadsheets, sticky notes, emails, and reliance on people. There was no CRM, no API-accessible tools, only a patchwork trying to stand in for a single source of truth.`,
- msg`The CEO had resisted bringing in a CRM for years. The business development team had no experience using one, and the licensing costs of well-known CRMs like Salesforce or HubSpot were hard to justify without any guarantee of adoption: CRMs are only as good as the maintenance of the data inside them.`,
- msg`In June 2025, Justin learned Twenty v1 had shipped. Within two or three days, the CEO asked him to look into setting up a CRM. The shift came from the potential of what could be built on top of fully open APIs. The timing was perfect.`,
- ],
- callout:
- '"It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other. Twenty made that possible in a way older CRM platforms simply do not." - Justin Beadle, Director of Digital and Information, Elevate Consulting',
- },
- {
- type: 'text',
- eyebrow: msg`Integration`,
- heading: [
- { text: msg`One`, fontFamily: 'serif' },
- { text: msg`API`, fontFamily: 'sans' },
- { text: msg`to rule them all`, fontFamily: 'serif' },
- ],
- paragraphs: [
- msg`Justin's broader mission at Elevate has been to move the company off static documents and onto tools with API access. By the end of 2025, that was in place: time billing, resource planning, Microsoft Teams, and project management were all accessible via API, with Twenty at the center holding client and opportunity data. Team members could use that information strategically instead of re-keying it.`,
- msg`That opened the door to something more powerful. Justin built a custom front end that pulls live data from those systems into a single view, tailored to each role. When a proposal is won, what used to require four separate people manually setting up instances across four different tools now happens in a single click, drawing on data collected in Twenty across the full opportunity lifecycle. It is another shift toward higher-value work for clients.`,
- msg`Twenty is not only where CRM data lives. It is the API backbone that makes the rest of the stack possible.`,
- ],
- callout:
- '"Because Twenty\'s API is fully open, I could connect it to every other tool in our stack. When a proposal is won, what used to take four people manually setting things up across four different tools now happens in a single click. That is the kind of time saving that only becomes possible when everything is connected." - Justin Beadle, Director of Digital and Information, Elevate Consulting',
- },
- {
- type: 'text',
- eyebrow: msg`Adoption`,
- heading: [
- { text: msg`Workflows that`, fontFamily: 'serif' },
- { text: msg`actually get used`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`The business development team finally had the CRM they had been asking for. Adoption came naturally: their data was already there when they logged in.`,
- msg`Justin built workflows for notifications across the team, alerting the right people in Teams when a prospect becomes a lead or when project milestones are reached. Forms in Twenty let the business development team log activity without leaving the tool. The impact is real for the organization. The tool has been adaptable from opportunity-level work at a client to executive-level decisions.`,
- msg`The flexibility to wire this together, without outside help and without fighting the platform, is what made it possible for a single person to stand up and maintain a connected stack across an entire consultancy.`,
- ],
- },
- {
- type: 'text',
- eyebrow: msg`What is next`,
- heading: [
- { text: msg`Beyond`, fontFamily: 'serif' },
- { text: msg`internal rollout`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`Elevate's CEO was so impressed with Twenty he started recommending it to clients before the internal setup was even complete. The team is exploring bringing Twenty to client projects as part of their consulting practice, including as the backend for custom-built products tailored to specific operational needs.`,
- msg`For a firm that once ran on sticky notes, this is more than an upgrade. It is a complete transformation.`,
- ],
- },
- ],
- tableOfContents: [
- msg`From documents to open APIs`,
- msg`One API to rule them all`,
- msg`Workflows that actually get used`,
- msg`What is next`,
- ],
- catalogCard: {
- summary: msg`Elevate Consulting uses Twenty as the API backbone connecting billing, Teams, resourcing, and a custom front end around client and opportunity data.`,
- date: msg`Jun 2025`,
- },
-};
+const META_TITLE = msg`Twenty as the API backbone of a go-to-market stack | Elevate Consulting`;
+const META_DESCRIPTION = msg`How Elevate Consulting moved off documents and spreadsheets to Twenty as the API-connected CRM at the center of their stack.`;
export const generateMetadata = buildLocalizedMetadata({
path: '/customers/elevate-consulting',
- title: CASE_STUDY.meta.title,
- description: CASE_STUDY.meta.description,
+ title: META_TITLE,
+ description: META_DESCRIPTION,
});
type CaseStudyPageProps = {
@@ -131,69 +27,139 @@ type CaseStudyPageProps = {
export default async function ElevateConsultingCaseStudyPage({
params,
}: CaseStudyPageProps) {
- const [i18n, stats] = await Promise.all([
- getRouteI18n(params),
- fetchCommunityStats(),
- ]);
- const renderText = createMessageDescriptorRenderer(i18n);
- const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
- const palette = getCaseStudyPalette('/customers/elevate-consulting');
+ const i18n = await getRouteI18n(params);
- let storySectionIndex = 0;
- const sectionBlocks = CASE_STUDY.sections.map((block, index) => {
- if (block.type === 'text') {
- const sectionId = `case-study-section-${storySectionIndex}`;
- storySectionIndex += 1;
- return (
-
- );
- }
- return (
-
- );
- });
+ const caseStudy: CaseStudyData = {
+ meta: {
+ title: META_TITLE,
+ description: META_DESCRIPTION,
+ },
+ hero: {
+ readingTime: '8 min',
+ title: (
+
+
+ {i18n._(msg`Twenty as the`)}
+
+
+
+ {i18n._(msg`API backbone`)}
+ {' '}
+
+ {i18n._(msg`of a go-to-market stack`)}
+
+
+ ),
+ author: 'Justin Beadle',
+ authorRole: msg`Director of Digital and Information, Elevate Consulting`,
+ clientIcon: 'elevate-consulting',
+ heroImageSrc: PLACEHOLDER_HERO,
+ industry: msg`Management Consulting`,
+ kpis: [
+ { value: msg`1 click`, label: msg`Proposal automation` },
+ { value: msg`4 tools`, label: msg`Connected via API` },
+ { value: msg`API-first`, label: msg`Tool integration` },
+ ],
+ quote: {
+ text: msg`It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other.`,
+ author: 'Justin Beadle',
+ role: msg`Director of Digital and Information, Elevate Consulting`,
+ },
+ },
+ sections: [
+ {
+ type: 'text',
+ eyebrow: msg`The situation`,
+ heading: (
+
+
+ {i18n._(msg`From documents to`)}
+ {' '}
+
+ {i18n._(msg`open APIs`)}
+
+
+ ),
+ paragraphs: [
+ msg`Elevate Consulting is a management consultancy based in Canada. When Justin Beadle, Director of Digital and Information, joined, the company ran entirely on Word documents, Excel spreadsheets, sticky notes, emails, and reliance on people. There was no CRM, no API-accessible tools, only a patchwork trying to stand in for a single source of truth.`,
+ msg`The CEO had resisted bringing in a CRM for years. The business development team had no experience using one, and the licensing costs of well-known CRMs like Salesforce or HubSpot were hard to justify without any guarantee of adoption: CRMs are only as good as the maintenance of the data inside them.`,
+ msg`In June 2025, Justin learned Twenty v1 had shipped. Within two or three days, the CEO asked him to look into setting up a CRM. The shift came from the potential of what could be built on top of fully open APIs. The timing was perfect.`,
+ ],
+ callout:
+ '"It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other. Twenty made that possible in a way older CRM platforms simply do not." - Justin Beadle, Director of Digital and Information, Elevate Consulting',
+ },
+ {
+ type: 'text',
+ eyebrow: msg`Integration`,
+ heading: (
+
+ {i18n._(msg`One`)} {' '}
+ {i18n._(msg`API`)} {' '}
+
+ {i18n._(msg`to rule them all`)}
+
+
+ ),
+ paragraphs: [
+ msg`Justin's broader mission at Elevate has been to move the company off static documents and onto tools with API access. By the end of 2025, that was in place: time billing, resource planning, Microsoft Teams, and project management were all accessible via API, with Twenty at the center holding client and opportunity data. Team members could use that information strategically instead of re-keying it.`,
+ msg`That opened the door to something more powerful. Justin built a custom front end that pulls live data from those systems into a single view, tailored to each role. When a proposal is won, what used to require four separate people manually setting up instances across four different tools now happens in a single click, drawing on data collected in Twenty across the full opportunity lifecycle. It is another shift toward higher-value work for clients.`,
+ msg`Twenty is not only where CRM data lives. It is the API backbone that makes the rest of the stack possible.`,
+ ],
+ callout:
+ '"Because Twenty\'s API is fully open, I could connect it to every other tool in our stack. When a proposal is won, what used to take four people manually setting things up across four different tools now happens in a single click. That is the kind of time saving that only becomes possible when everything is connected." - Justin Beadle, Director of Digital and Information, Elevate Consulting',
+ },
+ {
+ type: 'text',
+ eyebrow: msg`Adoption`,
+ heading: (
+
+
+ {i18n._(msg`Workflows that`)}
+ {' '}
+
+ {i18n._(msg`actually get used`)}
+
+
+ ),
+ paragraphs: [
+ msg`The business development team finally had the CRM they had been asking for. Adoption came naturally: their data was already there when they logged in.`,
+ msg`Justin built workflows for notifications across the team, alerting the right people in Teams when a prospect becomes a lead or when project milestones are reached. Forms in Twenty let the business development team log activity without leaving the tool. The impact is real for the organization. The tool has been adaptable from opportunity-level work at a client to executive-level decisions.`,
+ msg`The flexibility to wire this together, without outside help and without fighting the platform, is what made it possible for a single person to stand up and maintain a connected stack across an entire consultancy.`,
+ ],
+ },
+ {
+ type: 'text',
+ eyebrow: msg`What is next`,
+ heading: (
+
+ {i18n._(msg`Beyond`)} {' '}
+
+ {i18n._(msg`internal rollout`)}
+
+
+ ),
+ paragraphs: [
+ msg`Elevate's CEO was so impressed with Twenty he started recommending it to clients before the internal setup was even complete. The team is exploring bringing Twenty to client projects as part of their consulting practice, including as the backend for custom-built products tailored to specific operational needs.`,
+ msg`For a firm that once ran on sticky notes, this is more than an upgrade. It is a complete transformation.`,
+ ],
+ },
+ ],
+ tableOfContents: [
+ msg`From documents to open APIs`,
+ msg`One API to rule them all`,
+ msg`Workflows that actually get used`,
+ msg`What is next`,
+ ],
+ catalogCard: {
+ summary: msg`Elevate Consulting uses Twenty as the API backbone connecting billing, Teams, resourcing, and a custom front end around client and opportunity data.`,
+ date: msg`Jun 2025`,
+ },
+ };
return (
- <>
-
-
-
-
-
-
-
-
-
-
-
- {sectionBlocks}
-
-
-
- >
+
);
}
diff --git a/packages/twenty-website-new/src/app/[locale]/customers/netzero/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/netzero/page.tsx
index be61519d52..ea9ac5ba20 100644
--- a/packages/twenty-website-new/src/app/[locale]/customers/netzero/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/customers/netzero/page.tsx
@@ -1,118 +1,23 @@
import { msg } from '@lingui/core/macro';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
-import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
-import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
+import { type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
-import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { CaseStudy } from '@/sections/CaseStudy/components';
-import { Menu } from '@/sections/Menu/components';
-import { theme } from '@/theme';
+} from '@/lib/i18n/utils/get-route-i18n';
import { buildLocalizedMetadata } from '@/lib/seo';
+import { Heading, HeadingPart } from '@/design-system/components';
+import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1744830343976-ce690ba2a67c?w=1600&q=80';
-const CASE_STUDY: CaseStudyData = {
- meta: {
- title: msg`A CRM that grows with you | NetZero & Twenty`,
- description: msg`How NetZero uses Twenty across carbon credits, agricultural products, and franchised industrial systems with a modular CRM and a roadmap toward AI-assisted workflows.`,
- },
- hero: {
- readingTime: '8 min',
- title: [
- { text: msg`A CRM that`, fontFamily: 'serif' },
- { text: msg`grows`, fontFamily: 'sans', newLine: true },
- { text: msg`with you`, fontFamily: 'serif' },
- ],
- author: 'Olivier Reinaud',
- authorAvatarSrc: '/images/partner/testimonials/olivier-reinaud.jpg',
- authorRole: msg`Co-founder, NetZero`,
- clientIcon: 'netzero',
- heroImageSrc: PLACEHOLDER_HERO,
- industry: msg`Agribusiness`,
- kpis: [
- { value: msg`3 product lines`, label: msg`On a single CRM` },
- { value: msg`No-code`, label: msg`Customizations` },
- ],
- },
- sections: [
- {
- type: 'text',
- eyebrow: msg`NetZero`,
- heading: [
- { text: msg`The right`, fontFamily: 'serif' },
- { text: msg`foundation`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`NetZero works with the agro-industry, serving clients from multinationals to smallholder farmers. They sell carbon credits, agricultural products, and franchised industrial systems across three different product lines, multiple countries, and multiple company sizes. When Olivier Reinaud, co-founder of NetZero, started looking at CRMs in late 2024, he was not chasing the most feature-rich platform. He wanted the right foundation.`,
- ],
- callout:
- '"Twenty delivers on what CRMs should have always been: fairly priced software with a fully modular and customizable model, a clean and modern UI, granular permissions, automations, enterprise features. A compelling solution with high potential to rightfully disrupt the CRM market." - Olivier Reinaud, co-founder of NetZero',
- },
- {
- type: 'text',
- eyebrow: msg`Flexibility`,
- heading: [
- {
- text: msg`A business that does not fit a`,
- fontFamily: 'serif',
- },
- { text: msg`template`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`What convinced Olivier was the flexibility of the platform and where it was headed. Even when initial needs were basic record-keeping, he still needed a custom data model with granular permissions to manage the wide range of NetZero activities. He also needed a system that could adapt quickly to a fast-iteration company.`,
- msg`With Twenty, when a new need appears, he can address it himself: no developer required, no support ticket.`,
- ],
- callout:
- '"The flexibility is really what made the difference. Our needs evolve very fast. I discover a new need and in two clicks I can address it. That is a real advantage when you are moving quickly." - Olivier Reinaud, co-founder of NetZero',
- },
- {
- type: 'text',
- eyebrow: msg`Roadmap`,
- heading: [
- { text: msg`From simple to`, fontFamily: 'serif' },
- { text: msg`advanced`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`Olivier recognizes that NetZero's current use of Twenty is still relatively simple: workflows and integrations are not yet as deep as he eventually wants, because he prioritized getting foundations right first.`,
- msg`What is planned is significant. NetZero has a data lake, online forms, and multiple internal systems that he wants to connect to Twenty. The pipes are there; the next step is automations that tie them together.`,
- msg`What is coming in April 2026 is what he has been waiting for: AI-assisted workflow creation, describing what he needs and iterating from there instead of building complex logic from scratch. For a founder who runs the CRM himself, that changes what is realistically possible.`,
- ],
- },
- {
- type: 'text',
- eyebrow: msg`Results`,
- heading: [
- { text: msg`The bet is`, fontFamily: 'serif' },
- { text: msg`paying off`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`While NetZero still runs a second CRM in parallel for WhatsApp-heavy operations with farmers in Brazil, they expect to migrate all of it to Twenty as features and the ecosystem grow. Already, their structured, multinational pipeline is powered by Twenty.`,
- msg`The early bet on the architecture is holding, and upcoming AI features are expected to make it even more relevant.`,
- ],
- },
- ],
- tableOfContents: [
- msg`The right foundation`,
- msg`A business that does not fit a template`,
- msg`From simple to advanced`,
- msg`The bet is paying off`,
- ],
- catalogCard: {
- summary: msg`NetZero uses Twenty as a modular CRM across product lines and countries, with a roadmap into AI-assisted workflows.`,
- date: msg`2025`,
- },
-};
+const META_TITLE = msg`A CRM that grows with you | NetZero & Twenty`;
+const META_DESCRIPTION = msg`How NetZero uses Twenty across carbon credits, agricultural products, and franchised industrial systems with a modular CRM and a roadmap toward AI-assisted workflows.`;
export const generateMetadata = buildLocalizedMetadata({
path: '/customers/netzero',
- title: CASE_STUDY.meta.title,
- description: CASE_STUDY.meta.description,
+ title: META_TITLE,
+ description: META_DESCRIPTION,
});
type CaseStudyPageProps = {
@@ -122,69 +27,123 @@ type CaseStudyPageProps = {
export default async function NetZeroCaseStudyPage({
params,
}: CaseStudyPageProps) {
- const [i18n, stats] = await Promise.all([
- getRouteI18n(params),
- fetchCommunityStats(),
- ]);
- const renderText = createMessageDescriptorRenderer(i18n);
- const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
- const palette = getCaseStudyPalette('/customers/netzero');
+ const i18n = await getRouteI18n(params);
- let storySectionIndex = 0;
- const sectionBlocks = CASE_STUDY.sections.map((block, index) => {
- if (block.type === 'text') {
- const sectionId = `case-study-section-${storySectionIndex}`;
- storySectionIndex += 1;
- return (
-
- );
- }
- return (
-
- );
- });
+ const caseStudy: CaseStudyData = {
+ meta: {
+ title: META_TITLE,
+ description: META_DESCRIPTION,
+ },
+ hero: {
+ readingTime: '8 min',
+ title: (
+
+
+ {i18n._(msg`A CRM that`)}
+
+
+ {i18n._(msg`grows`)} {' '}
+ {i18n._(msg`with you`)}
+
+ ),
+ author: 'Olivier Reinaud',
+ authorAvatarSrc: '/images/partner/testimonials/olivier-reinaud.webp',
+ authorRole: msg`Co-founder, NetZero`,
+ clientIcon: 'netzero',
+ heroImageSrc: PLACEHOLDER_HERO,
+ industry: msg`Agribusiness`,
+ kpis: [
+ { value: msg`3 product lines`, label: msg`On a single CRM` },
+ { value: msg`No-code`, label: msg`Customizations` },
+ ],
+ },
+ sections: [
+ {
+ type: 'text',
+ eyebrow: msg`NetZero`,
+ heading: (
+
+
+ {i18n._(msg`The right`)}
+ {' '}
+
+ {i18n._(msg`foundation`)}
+
+
+ ),
+ paragraphs: [
+ msg`NetZero works with the agro-industry, serving clients from multinationals to smallholder farmers. They sell carbon credits, agricultural products, and franchised industrial systems across three different product lines, multiple countries, and multiple company sizes. When Olivier Reinaud, co-founder of NetZero, started looking at CRMs in late 2024, he was not chasing the most feature-rich platform. He wanted the right foundation.`,
+ ],
+ callout:
+ '"Twenty delivers on what CRMs should have always been: fairly priced software with a fully modular and customizable model, a clean and modern UI, granular permissions, automations, enterprise features. A compelling solution with high potential to rightfully disrupt the CRM market." - Olivier Reinaud, co-founder of NetZero',
+ },
+ {
+ type: 'text',
+ eyebrow: msg`Flexibility`,
+ heading: (
+
+
+ {i18n._(msg`A business that does not fit a`)}
+ {' '}
+ {i18n._(msg`template`)}
+
+ ),
+ paragraphs: [
+ msg`What convinced Olivier was the flexibility of the platform and where it was headed. Even when initial needs were basic record-keeping, he still needed a custom data model with granular permissions to manage the wide range of NetZero activities. He also needed a system that could adapt quickly to a fast-iteration company.`,
+ msg`With Twenty, when a new need appears, he can address it himself: no developer required, no support ticket.`,
+ ],
+ callout:
+ '"The flexibility is really what made the difference. Our needs evolve very fast. I discover a new need and in two clicks I can address it. That is a real advantage when you are moving quickly." - Olivier Reinaud, co-founder of NetZero',
+ },
+ {
+ type: 'text',
+ eyebrow: msg`Roadmap`,
+ heading: (
+
+
+ {i18n._(msg`From simple to`)}
+ {' '}
+ {i18n._(msg`advanced`)}
+
+ ),
+ paragraphs: [
+ msg`Olivier recognizes that NetZero's current use of Twenty is still relatively simple: workflows and integrations are not yet as deep as he eventually wants, because he prioritized getting foundations right first.`,
+ msg`What is planned is significant. NetZero has a data lake, online forms, and multiple internal systems that he wants to connect to Twenty. The pipes are there; the next step is automations that tie them together.`,
+ msg`What is coming in April 2026 is what he has been waiting for: AI-assisted workflow creation, describing what he needs and iterating from there instead of building complex logic from scratch. For a founder who runs the CRM himself, that changes what is realistically possible.`,
+ ],
+ },
+ {
+ type: 'text',
+ eyebrow: msg`Results`,
+ heading: (
+
+
+ {i18n._(msg`The bet is`)}
+ {' '}
+
+ {i18n._(msg`paying off`)}
+
+
+ ),
+ paragraphs: [
+ msg`While NetZero still runs a second CRM in parallel for WhatsApp-heavy operations with farmers in Brazil, they expect to migrate all of it to Twenty as features and the ecosystem grow. Already, their structured, multinational pipeline is powered by Twenty.`,
+ msg`The early bet on the architecture is holding, and upcoming AI features are expected to make it even more relevant.`,
+ ],
+ },
+ ],
+ tableOfContents: [
+ msg`The right foundation`,
+ msg`A business that does not fit a template`,
+ msg`From simple to advanced`,
+ msg`The bet is paying off`,
+ ],
+ catalogCard: {
+ summary: msg`NetZero uses Twenty as a modular CRM across product lines and countries, with a roadmap into AI-assisted workflows.`,
+ date: msg`2025`,
+ },
+ };
return (
- <>
-
-
-
-
-
-
-
-
-
-
-
- {sectionBlocks}
-
-
-
- >
+
);
}
diff --git a/packages/twenty-website-new/src/app/[locale]/customers/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/page.tsx
index 14b668f1af..0612ce01f2 100644
--- a/packages/twenty-website-new/src/app/[locale]/customers/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/customers/page.tsx
@@ -1,38 +1,25 @@
import { msg } from '@lingui/core/macro';
-import { FAQ_DATA } from '@/sections/Faq/data';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
-import { TalkToUsButton } from '@/lib/contact-cal';
+import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
+import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
+import { TalkToUsButton } from '@/sections/ContactCal';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
import { Eyebrow, HeadingPart, LinkButton } from '@/design-system/components';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
+} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog/components';
-import { Faq } from '@/sections/Faq/components';
-import { Hero } from '@/sections/Hero/components';
-import { Menu } from '@/sections/Menu/components';
-import { Signoff } from '@/sections/Signoff/components';
-import { TrustedBy } from '@/sections/TrustedBy/components';
-import { theme } from '@/theme';
+import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog';
+import { Hero } from '@/sections/Hero';
+import { Menu, MENU_DATA } from '@/sections/Menu';
+import { Signoff } from '@/sections/Signoff';
import { buildRouteMetadata } from '@/lib/seo';
import { css } from '@linaria/core';
export const generateMetadata = buildRouteMetadata('customers');
-const HERO_BODY = {
- text: msg`Real stories from real teams about how they shaped Twenty to fit their workflow and accelerated their growth.`,
-};
-
-const SIGNOFF_BODY = {
- text: msg`Join the teams that chose to own their CRM.\nStart building with Twenty today.`,
-};
-
const CUSTOMERS_TOP_BACKGROUND_COLOR = '#F4F4F4';
const pageRevealClassName = css`
@@ -72,7 +59,6 @@ export default async function CaseStudiesCatalogPage({
getRouteI18n(params),
fetchCommunityStats(),
]);
- const renderText = createMessageDescriptorRenderer(i18n);
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
@@ -90,71 +76,56 @@ export default async function CaseStudiesCatalogPage({
-
+
- {renderText(msg`See how teams`)}
+ {i18n._(msg`See how teams`)}
- {renderText(msg`build`)}
+ {i18n._(msg`build`)}
{' '}
- {renderText(msg`on Twenty`)}
+ {i18n._(msg`on Twenty`)}
-
+
+ {i18n._(
+ msg`Real stories from real teams about how they shaped Twenty to fit their workflow and accelerated their growth.`,
+ )}
+
-
-
-
-
+ separator={i18n._(msg`trusted by`)}
+ logos={TRUSTED_BY_LOGOS}
+ clientCount={i18n._(msg`+10k others`)}
+ />
-
+
-
+
- {renderText(msg`Ready to build`)}
+ {i18n._(msg`Ready to build`)}
- {renderText(msg`your own story?`)}
+ {i18n._(msg`your own story?`)}
-
+
+ {i18n._(
+ msg`Join the teams that chose to own their CRM.\nStart building with Twenty today.`,
+ )}
+
-
+
+
+ {i18n._(msg`Any Questions?`)}
+
+
- {renderText(msg`Stop fighting custom.`)}
+ {i18n._(msg`Stop fighting custom.`)}
- {renderText(msg`Start building, with Twenty`)}
+ {i18n._(msg`Start building, with Twenty`)}
-
+
>
);
diff --git a/packages/twenty-website-new/src/app/[locale]/customers/w3villa/page.tsx b/packages/twenty-website-new/src/app/[locale]/customers/w3villa/page.tsx
index 0314103f80..6ae2884076 100644
--- a/packages/twenty-website-new/src/app/[locale]/customers/w3villa/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/customers/w3villa/page.tsx
@@ -1,117 +1,23 @@
import { msg } from '@lingui/core/macro';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
-import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
-import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
+import { type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
-import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { CaseStudy } from '@/sections/CaseStudy/components';
-import { Menu } from '@/sections/Menu/components';
-import { theme } from '@/theme';
+} from '@/lib/i18n/utils/get-route-i18n';
import { buildLocalizedMetadata } from '@/lib/seo';
+import { Heading, HeadingPart } from '@/design-system/components';
+import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1756830231350-3b501f63c5c1?w=1600&q=80';
-const CASE_STUDY: CaseStudyData = {
- meta: {
- title: msg`When your CRM is the product: W3Grads on Twenty | W3villa Technologies`,
- description: msg`How W3villa Technologies shipped W3Grads, an AI mock interview platform for institutions, on Twenty as the operational backbone.`,
- },
- hero: {
- readingTime: '8 min',
- title: [
- { text: msg`When your CRM is`, fontFamily: 'serif' },
- { text: msg`the product`, fontFamily: 'sans', newLine: true },
- ],
- author: 'Amrendra Pratap Singh',
- authorAvatarSrc: '/images/partner/testimonials/amrendra-singh.webp',
- authorRole: msg`VP of Engineering, W3villa Technologies`,
- clientIcon: 'w3villa',
- heroImageSrc: PLACEHOLDER_HERO,
- industry: msg`EdTech`,
- kpis: [{ value: msg`Zero`, label: msg`Manual work at core` }],
- },
- sections: [
- {
- type: 'text',
- eyebrow: msg`W3Grads`,
- heading: [
- { text: msg`Scale without`, fontFamily: 'serif' },
- { text: msg`breaking operations`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`Running mock interview programs for hundreds of students sounds straightforward. In practice, universities and training institutes hit the same wall: registrations entered by hand, interview links sent one by one, faculty reviewing every session without scoring or classification. At real scale, it breaks.`,
- msg`W3villa Technologies set out to solve it properly, not with a workaround, but with a product.`,
- ],
- callout:
- '"We did not want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind." - Amrendra Pratap Singh, VP of Engineering, W3villa Technologies',
- },
- {
- type: 'text',
- eyebrow: msg`Architecture`,
- heading: [
- {
- text: msg`Focus on the use case, not the`,
- fontFamily: 'serif',
- },
- { text: msg`plumbing`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`W3villa built W3Grads (w3grads.com), an AI-powered mock interview platform for universities and training institutes, using Twenty as its operational backbone.`,
- msg`The key decision was not to build everything from scratch. Twenty covers the data model, permissions, authentication, and workflow engine, the parts that would have taken months to rebuild, so the team could focus on product-specific logic.`,
- msg`When a student registers via QR at a campus event, the system assigns a plan, generates an interview session, and sends a link. The AI conducts the interview, scores the candidate, and classifies the result. Faculty see where each student stands without manually reviewing every session. Building and iterating on these workflows was faster with AI in the loop.`,
- ],
- },
- {
- type: 'text',
- eyebrow: msg`Scale`,
- heading: [
- {
- text: msg`A platform ready to`,
- fontFamily: 'serif',
- },
- { text: msg`grow`, fontFamily: 'sans' },
- ],
- paragraphs: [
- msg`Because the foundation is solid, W3Grads is architected for what comes next, including a payment layer for future paid interview plans and nationwide scale without structural rewrites.`,
- ],
- callout:
- '"Twenty gave us the flexibility to model the entire interview lifecycle as custom objects and workflows. We could build something genuinely complex without fighting the platform to do it." - Piyush Khandelwal, Director, W3villa Technologies, Partner',
- },
- {
- type: 'text',
- eyebrow: msg`The result`,
- heading: [
- { text: msg`Zero manual work`, fontFamily: 'sans' },
- { text: msg`at the core`, fontFamily: 'serif' },
- ],
- paragraphs: [
- msg`Programs that previously needed heavy manual coordination now run end-to-end with automation. Institutions get a scalable, intelligent system; students get faster preparation for interviews that matter; W3villa shipped a product institutions can build revenue around.`,
- msg`Zero manual work at the core. Full automation. Built on Twenty.`,
- ],
- },
- ],
- tableOfContents: [
- msg`Scale without breaking operations`,
- msg`Focus on the use case, not the plumbing`,
- msg`A platform ready to grow`,
- msg`The result`,
- ],
- catalogCard: {
- summary: msg`W3villa shipped W3Grads on Twenty for AI interviews, scoring, and institution-scale workflows without rebuilding CRM plumbing.`,
- date: msg`2025`,
- },
-};
+const META_TITLE = msg`When your CRM is the product: W3Grads on Twenty | W3villa Technologies`;
+const META_DESCRIPTION = msg`How W3villa Technologies shipped W3Grads, an AI mock interview platform for institutions, on Twenty as the operational backbone.`;
export const generateMetadata = buildLocalizedMetadata({
path: '/customers/w3villa',
- title: CASE_STUDY.meta.title,
- description: CASE_STUDY.meta.description,
+ title: META_TITLE,
+ description: META_DESCRIPTION,
});
type CaseStudyPageProps = {
@@ -121,69 +27,121 @@ type CaseStudyPageProps = {
export default async function W3villaCaseStudyPage({
params,
}: CaseStudyPageProps) {
- const [i18n, stats] = await Promise.all([
- getRouteI18n(params),
- fetchCommunityStats(),
- ]);
- const renderText = createMessageDescriptorRenderer(i18n);
- const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
- const palette = getCaseStudyPalette('/customers/w3villa');
+ const i18n = await getRouteI18n(params);
- let storySectionIndex = 0;
- const sectionBlocks = CASE_STUDY.sections.map((block, index) => {
- if (block.type === 'text') {
- const sectionId = `case-study-section-${storySectionIndex}`;
- storySectionIndex += 1;
- return (
-
- );
- }
- return (
-
- );
- });
+ const caseStudy: CaseStudyData = {
+ meta: {
+ title: META_TITLE,
+ description: META_DESCRIPTION,
+ },
+ hero: {
+ readingTime: '8 min',
+ title: (
+
+
+ {i18n._(msg`When your CRM is`)}
+
+
+
+ {i18n._(msg`the product`)}
+
+
+ ),
+ author: 'Amrendra Pratap Singh',
+ authorAvatarSrc: '/images/partner/testimonials/amrendra-singh.webp',
+ authorRole: msg`VP of Engineering, W3villa Technologies`,
+ clientIcon: 'w3villa',
+ heroImageSrc: PLACEHOLDER_HERO,
+ industry: msg`EdTech`,
+ kpis: [{ value: msg`Zero`, label: msg`Manual work at core` }],
+ },
+ sections: [
+ {
+ type: 'text',
+ eyebrow: msg`W3Grads`,
+ heading: (
+
+
+ {i18n._(msg`Scale without`)}
+ {' '}
+
+ {i18n._(msg`breaking operations`)}
+
+
+ ),
+ paragraphs: [
+ msg`Running mock interview programs for hundreds of students sounds straightforward. In practice, universities and training institutes hit the same wall: registrations entered by hand, interview links sent one by one, faculty reviewing every session without scoring or classification. At real scale, it breaks.`,
+ msg`W3villa Technologies set out to solve it properly, not with a workaround, but with a product.`,
+ ],
+ callout:
+ '"We did not want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind." - Amrendra Pratap Singh, VP of Engineering, W3villa Technologies',
+ },
+ {
+ type: 'text',
+ eyebrow: msg`Architecture`,
+ heading: (
+
+
+ {i18n._(msg`Focus on the use case, not the`)}
+ {' '}
+ {i18n._(msg`plumbing`)}
+
+ ),
+ paragraphs: [
+ msg`W3villa built W3Grads (w3grads.com), an AI-powered mock interview platform for universities and training institutes, using Twenty as its operational backbone.`,
+ msg`The key decision was not to build everything from scratch. Twenty covers the data model, permissions, authentication, and workflow engine, the parts that would have taken months to rebuild, so the team could focus on product-specific logic.`,
+ msg`When a student registers via QR at a campus event, the system assigns a plan, generates an interview session, and sends a link. The AI conducts the interview, scores the candidate, and classifies the result. Faculty see where each student stands without manually reviewing every session. Building and iterating on these workflows was faster with AI in the loop.`,
+ ],
+ },
+ {
+ type: 'text',
+ eyebrow: msg`Scale`,
+ heading: (
+
+
+ {i18n._(msg`A platform ready to`)}
+ {' '}
+ {i18n._(msg`grow`)}
+
+ ),
+ paragraphs: [
+ msg`Because the foundation is solid, W3Grads is architected for what comes next, including a payment layer for future paid interview plans and nationwide scale without structural rewrites.`,
+ ],
+ callout:
+ '"Twenty gave us the flexibility to model the entire interview lifecycle as custom objects and workflows. We could build something genuinely complex without fighting the platform to do it." - Piyush Khandelwal, Director, W3villa Technologies, Partner',
+ },
+ {
+ type: 'text',
+ eyebrow: msg`The result`,
+ heading: (
+
+
+ {i18n._(msg`Zero manual work`)}
+ {' '}
+
+ {i18n._(msg`at the core`)}
+
+
+ ),
+ paragraphs: [
+ msg`Programs that previously needed heavy manual coordination now run end-to-end with automation. Institutions get a scalable, intelligent system; students get faster preparation for interviews that matter; W3villa shipped a product institutions can build revenue around.`,
+ msg`Zero manual work at the core. Full automation. Built on Twenty.`,
+ ],
+ },
+ ],
+ tableOfContents: [
+ msg`Scale without breaking operations`,
+ msg`Focus on the use case, not the plumbing`,
+ msg`A platform ready to grow`,
+ msg`The result`,
+ ],
+ catalogCard: {
+ summary: msg`W3villa shipped W3Grads on Twenty for AI interviews, scoring, and institution-scale workflows without rebuilding CRM plumbing.`,
+ date: msg`2025`,
+ },
+ };
return (
- <>
-
-
-
-
-
-
-
-
-
-
-
- {sectionBlocks}
-
-
-
- >
+
);
}
diff --git a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/EnterpriseActivateClient.tsx b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/EnterpriseActivateClient.tsx
index 27ef265792..bd02d9ffe8 100644
--- a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/EnterpriseActivateClient.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/EnterpriseActivateClient.tsx
@@ -5,20 +5,16 @@ import {
BaseButton,
buttonBaseStyles,
} from '@/design-system/components/Button/BaseButton';
-import { Body, Heading } from '@/design-system/components';
-import { useRenderMessage } from '@/lib/i18n/use-render-message';
+import { Body, Heading, HeadingPart } from '@/design-system/components';
+import { useLingui } from '@lingui/react';
import { useTimeoutRegistry } from '@/lib/react';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import { useSearchParams } from 'next/navigation';
-import { useEffect, useState } from 'react';
+import { useState } from 'react';
-type ActivationResult = {
- enterpriseKey: string;
- licensee: string;
- subscriptionId: string;
-};
+import { useEnterpriseActivation } from './use-enterprise-activation';
const ContentStack = styled.div`
display: flex;
@@ -140,73 +136,13 @@ const nextStepItemClassName = css`
`;
export function EnterpriseActivateClient() {
- const renderText = useRenderMessage();
+ const { i18n } = useLingui();
const searchParams = useSearchParams();
const sessionId = searchParams.get('session_id');
const timeoutRegistry = useTimeoutRegistry();
- const [result, setResult] = useState(null);
- const [error, setError] = useState(null);
- const [loading, setLoading] = useState(true);
+ const { result, error, loading } = useEnterpriseActivation(sessionId);
const [copied, setCopied] = useState(false);
- useEffect(() => {
- if (!sessionId) {
- setError('No session ID provided. Please complete the checkout first.');
- setLoading(false);
-
- return;
- }
-
- const abortController = new AbortController();
-
- const activate = async () => {
- try {
- const response = await fetch(
- `/api/enterprise/activate?session_id=${encodeURIComponent(sessionId)}`,
- { signal: abortController.signal },
- );
- const data: { error?: string } & Partial =
- await response.json();
-
- if (abortController.signal.aborted) {
- return;
- }
-
- if (!response.ok) {
- setError(data.error ?? 'Activation failed');
-
- return;
- }
-
- if (data.enterpriseKey && data.licensee && data.subscriptionId) {
- setResult({
- enterpriseKey: data.enterpriseKey,
- licensee: data.licensee,
- subscriptionId: data.subscriptionId,
- });
- } else {
- setError('Activation response was incomplete.');
- }
- } catch {
- if (abortController.signal.aborted) {
- return;
- }
-
- setError('Failed to activate enterprise key. Please try again.');
- } finally {
- if (!abortController.signal.aborted) {
- setLoading(false);
- }
- }
- };
-
- void activate();
-
- return () => {
- abortController.abort();
- };
- }, [sessionId]);
-
const handleCopy = async () => {
if (!result) {
return;
@@ -222,55 +158,39 @@ export function EnterpriseActivateClient() {
return (
{loading && (
-
+
+ {i18n._(msg`Activating your enterprise license…`)}
+
)}
{error !== null && {error} }
{result !== null && (
<>
-
+
+ {i18n._(
+ msg`Your enterprise license has been activated successfully.`,
+ )}
+
- {' '}
+
+ {i18n._(msg`Licensee:`)}
+ {' '}
{result.licensee}
-
-
+
+
+ {i18n._(msg`Your enterprise key`)}
+
+
+
+ {i18n._(
+ msg`Copy this key and paste it into your Twenty self-hosted instance settings.`,
+ )}
+
{result.enterpriseKey}
@@ -284,7 +204,7 @@ export function EnterpriseActivateClient() {
>
@@ -293,36 +213,28 @@ export function EnterpriseActivateClient() {
-
+
+
+ {i18n._(msg`Next steps`)}
+
+
-
+
+ {i18n._(msg`Copy the enterprise key above.`)}
+
-
+
+ {i18n._(
+ msg`Open your Twenty self-hosted instance Settings → Enterprise.`,
+ )}
+
-
+
+ {i18n._(msg`Paste the key and click Activate.`)}
+
diff --git a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/page.tsx b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/page.tsx
index c53a9edad7..dceb039881 100644
--- a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/page.tsx
@@ -1,5 +1,4 @@
import { msg } from '@lingui/core/macro';
-import { MENU_DATA } from '@/sections/Menu/data';
import { EnterpriseActivateClient } from '@/app/[locale]/enterprise/activate/EnterpriseActivateClient';
import {
Body,
@@ -8,15 +7,14 @@ import {
HeadingPart,
} from '@/design-system/components';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
+} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { Hero } from '@/sections/Hero/components';
-import { Menu } from '@/sections/Menu/components';
+import { Hero } from '@/sections/Hero';
+import { Menu, MENU_DATA } from '@/sections/Menu';
import { theme } from '@/theme';
import { buildRouteMetadata } from '@/lib/seo';
import { Suspense } from 'react';
@@ -24,10 +22,6 @@ import { styled } from '@linaria/react';
export const generateMetadata = buildRouteMetadata('enterpriseActivate');
-const ENTERPRISE_ACTIVATE_BODY = {
- text: msg`Your checkout is complete. Follow the steps below to copy your license key into your Twenty instance.`,
-};
-
const ActivatePageContent = styled.section`
background-color: ${theme.colors.primary.background[100]};
flex: 1;
@@ -51,7 +45,9 @@ function EnterpriseActivateFallback({
loadingLabel,
}: EnterpriseActivateFallbackProps) {
return (
-
+
+ {loadingLabel}
+
);
}
@@ -66,7 +62,6 @@ export default async function EnterpriseActivatePage({
getRouteI18n(params),
fetchCommunityStats(),
]);
- const renderText = createMessageDescriptorRenderer(i18n);
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
@@ -83,28 +78,23 @@ export default async function EnterpriseActivatePage({
-
-
+
+
+
+ {i18n._(msg`Self-hosting`)}
+
+
- {renderText(msg`Enterprise`)}
+ {i18n._(msg`Enterprise`)}
{' '}
-
- {renderText(msg`activation`)}
-
+ {i18n._(msg`activation`)}
-
+
+ {i18n._(
+ msg`Your checkout is complete. Follow the steps below to copy your license key into your Twenty instance.`,
+ )}
+
@@ -113,7 +103,7 @@ export default async function EnterpriseActivatePage({
}
>
diff --git a/packages/twenty-website-new/src/app/[locale]/enterprise/activate/use-enterprise-activation.ts b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/use-enterprise-activation.ts
new file mode 100644
index 0000000000..7f0fc55446
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/enterprise/activate/use-enterprise-activation.ts
@@ -0,0 +1,80 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+type ActivationResult = {
+ enterpriseKey: string;
+ licensee: string;
+ subscriptionId: string;
+};
+
+type EnterpriseActivationState = {
+ error: string | null;
+ loading: boolean;
+ result: ActivationResult | null;
+};
+
+export function useEnterpriseActivation(
+ sessionId: string | null,
+): EnterpriseActivationState {
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!sessionId) {
+ setError('No session ID provided. Please complete the checkout first.');
+ setLoading(false);
+ return;
+ }
+
+ const abortController = new AbortController();
+
+ const activate = async () => {
+ try {
+ const response = await fetch(
+ `/api/enterprise/activate?session_id=${encodeURIComponent(sessionId)}`,
+ { signal: abortController.signal },
+ );
+ const data: { error?: string } & Partial =
+ await response.json();
+
+ if (abortController.signal.aborted) {
+ return;
+ }
+
+ if (!response.ok) {
+ setError(data.error ?? 'Activation failed');
+ return;
+ }
+
+ if (data.enterpriseKey && data.licensee && data.subscriptionId) {
+ setResult({
+ enterpriseKey: data.enterpriseKey,
+ licensee: data.licensee,
+ subscriptionId: data.subscriptionId,
+ });
+ } else {
+ setError('Activation response was incomplete.');
+ }
+ } catch {
+ if (abortController.signal.aborted) {
+ return;
+ }
+ setError('Failed to activate enterprise key. Please try again.');
+ } finally {
+ if (!abortController.signal.aborted) {
+ setLoading(false);
+ }
+ }
+ };
+
+ void activate();
+
+ return () => {
+ abortController.abort();
+ };
+ }, [sessionId]);
+
+ return { error, loading, result };
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/ControlsPanel.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/ControlsPanel.tsx
index 223715f717..25d39e1b8e 100644
--- a/packages/twenty-website-new/src/app/[locale]/halftone/_components/ControlsPanel.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/ControlsPanel.tsx
@@ -1,7 +1,7 @@
'use client';
-import type { ReactExportSettings } from '@/app/[locale]/halftone/_lib/exporters';
-import { IconLayoutSidebarRightCollapse, IconShare } from '@tabler/icons-react';
+import type { ReactExportSettings } from '@/lib/halftone/utils/exporters';
+import { IconLayoutSidebarRightCollapse } from '@tabler/icons-react';
import { styled } from '@linaria/react';
import type {
HalftoneBackgroundSettings,
@@ -9,7 +9,7 @@ import type {
HalftoneSourceMode,
HalftoneStudioSettings,
HalftoneTabId,
-} from '@/lib/halftone/state';
+} from '@/lib/halftone/utils/state';
import { AnimationsTab } from './controls/AnimationsTab';
import { DesignTab } from './controls/DesignTab';
import { ExportTab } from './controls/ExportTab';
@@ -25,7 +25,6 @@ type ControlsPanelProps = {
value: Partial,
) => void;
onBackgroundChange: (value: Partial) => void;
- onCopyShareLink: () => void;
onDashColorChange: (value: string) => void;
onHoverDashColorChange: (value: string) => void;
onCopyHalftoneImage: (width: number, height: number) => void;
@@ -133,7 +132,6 @@ export function ControlsPanel({
imageFileName,
onAnimationSettingsChange,
onBackgroundChange,
- onCopyShareLink,
onDashColorChange,
onHoverDashColorChange,
onCopyHalftoneImage,
@@ -184,14 +182,6 @@ export function ControlsPanel({
) : null}
-
-
-
;
-const DESKTOP_CONTROLS_PANEL_WIDTH = 320;
-const DESKTOP_CONTROLS_PANEL_OFFSET = 20;
-const DESKTOP_CONTROLS_PANEL_FOOTPRINT =
- DESKTOP_CONTROLS_PANEL_WIDTH + DESKTOP_CONTROLS_PANEL_OFFSET;
+import { CopyHalftoneShortcutEffect } from './effect-components/CopyHalftoneShortcutEffect';
+import { PasteImageEffect } from './effect-components/PasteImageEffect';
+import { useDefaultHalftoneImage } from '../_hooks/use-default-halftone-image';
+import { useGeometryLoader } from '../_hooks/use-geometry-loader';
+import { useImageElement } from '../_hooks/use-image-element';
+
+import {
+ CanvasLayer,
+ ContentRegion,
+ ControlsPanelFrame,
+ ControlsPositioner,
+ HiddenFileInput,
+ Hint,
+ LogoLink,
+ Status,
+ StudioShell,
+} from './halftone-studio.styles';
+
const STATUS_CLEAR_DELAY_MS = 2000;
-const StudioShell = styled.div<{ $background: string }>`
- background: ${(props) => props.$background};
- /* Full-screen tool: track the visible viewport so the bottom controls bar
- * is reachable on mobile Safari (where 100vh extends behind the URL bar). */
- height: 100vh;
- height: 100dvh;
- overflow: hidden;
- position: relative;
- width: 100%;
-`;
-
-const ContentRegion = styled.div<{ $panelOpen: boolean }>`
- height: 100%;
- margin-right: auto;
- position: relative;
- transition: width 0.18s ease;
- width: ${(props) =>
- props.$panelOpen
- ? `calc(100% - ${DESKTOP_CONTROLS_PANEL_FOOTPRINT}px)`
- : '100%'};
-
- @media (max-width: ${theme.breakpoints.md - 1}px) {
- width: 100%;
- }
-`;
-
-const CanvasLayer = styled.div`
- height: 100%;
- width: 100%;
-`;
-
-const LogoLink = styled(LocalizedLink)`
- display: grid;
- left: 24px;
- position: absolute;
- top: 24px;
- z-index: 2;
-`;
-
-const Hint = styled.div<{ $tone: 'dark' | 'light'; $visible: boolean }>`
- color: ${(props) =>
- props.$tone === 'dark'
- ? 'rgba(255, 255, 255, 0.34)'
- : 'rgba(0, 0, 0, 0.3)'};
- font-family: ${theme.font.family.mono};
- font-size: 11px;
- left: 50%;
- opacity: ${(props) => (props.$visible ? 1 : 0)};
- pointer-events: none;
- position: absolute;
- top: 20px;
- transform: translateX(-50%);
- transition: opacity 0.3s ease;
- z-index: 1;
-`;
-
-const Status = styled.div<{
- $error: boolean;
- $tone: 'dark' | 'light';
- $visible: boolean;
-}>`
- color: ${(props) => {
- if (props.$error) {
- return 'rgba(214, 66, 66, 0.92)';
- }
-
- return props.$tone === 'dark'
- ? 'rgba(255, 255, 255, 0.62)'
- : 'rgba(0, 0, 0, 0.55)';
- }};
- font-family: ${theme.font.family.mono};
- font-size: 11px;
- left: 50%;
- opacity: ${(props) => (props.$visible ? 1 : 0)};
- pointer-events: none;
- position: absolute;
- top: 44px;
- transform: translateX(-50%);
- transition: opacity 0.2s ease;
- z-index: 1;
-`;
-
-const ControlsPositioner = styled.div`
- align-items: flex-start;
- bottom: 20px;
- display: flex;
- height: calc(100vh - 40px);
- height: calc(100dvh - 40px);
- justify-content: flex-end;
- pointer-events: none;
- position: fixed;
- right: 20px;
- top: 20px;
- z-index: 3;
-
- @media (max-width: ${theme.breakpoints.md - 1}px) {
- bottom: 16px;
- height: 48vh;
- left: 16px;
- right: 16px;
- top: auto;
- }
-`;
-
-const ControlsPanelFrame = styled.div<{ $visible: boolean }>`
- height: ${(props) => (props.$visible ? '100%' : 'auto')};
- pointer-events: auto;
- @media (max-width: ${theme.breakpoints.md - 1}px) {
- width: ${(props) => (props.$visible ? '100%' : 'auto')};
- }
-`;
-
-const HiddenFileInput = styled.input`
- display: none;
-`;
-
const DEFAULT_PREVIEW_DISTANCE = 6;
const DEFAULT_IMAGE_ASSET_PATH = '/images/shared/halftone/twenty-logo.svg';
const DEFAULT_IMAGE_FILENAME = 'twenty-logo.svg';
-const CLIPBOARD_IMAGE_EXTENSION_BY_MIME: Record = {
- 'image/bmp': '.bmp',
- 'image/gif': '.gif',
- 'image/jpeg': '.jpg',
- 'image/png': '.png',
- 'image/svg+xml': '.svg',
- 'image/webp': '.webp',
-};
type PendingFilePicker = {
resolve: (file: File | null) => void;
};
@@ -239,42 +115,6 @@ function getAssetFilenameFromUrl(assetUrl: string, fallbackFilename: string) {
: fallbackFilename;
}
-function isEditablePasteTarget(target: EventTarget | null) {
- if (!(target instanceof HTMLElement)) {
- return false;
- }
-
- return (
- target.isContentEditable ||
- target instanceof HTMLInputElement ||
- target instanceof HTMLTextAreaElement ||
- target.closest('[contenteditable=""], [contenteditable="true"]') !== null
- );
-}
-
-function createPastedImageFile(file: File) {
- const hasExtension = /\.[^.]+$/i.test(file.name);
-
- if (hasExtension) {
- return file;
- }
-
- const extension =
- CLIPBOARD_IMAGE_EXTENSION_BY_MIME[file.type] ??
- CLIPBOARD_IMAGE_EXTENSION_BY_MIME['image/png'];
-
- return new File([file], `pasted-image-${Date.now()}${extension}`, {
- type: file.type || 'image/png',
- lastModified: Date.now(),
- });
-}
-
-function hasTextSelection() {
- const selection = window.getSelection();
-
- return selection !== null && selection.toString().trim().length > 0;
-}
-
function createInitialExportPose(): HalftoneExportPose {
return {
autoElapsed: 0,
@@ -358,18 +198,12 @@ export function HalftoneStudio() {
const [previewDistance, setPreviewDistance] = useState(
DEFAULT_PREVIEW_DISTANCE,
);
- const [activeGeometry, setActiveGeometry] = useState(
- () => createFallbackGeometry(),
- );
const [exportBackground, setExportBackground] = useState(false);
const [exportName, setExportName] = useState('');
const [reactExportSettings, setReactExportSettings] =
useState(DEFAULT_REACT_EXPORT_SETTINGS);
const [reactAssetPublicUrl, setReactAssetPublicUrl] = useState('');
const [imageFile, setImageFile] = useState(null);
- const [imageElement, setImageElement] = useState(
- null,
- );
const [canvasInitialPose, setCanvasInitialPose] =
useState();
const defaultImageFileReference = useRef(null);
@@ -380,10 +214,6 @@ export function HalftoneStudio() {
const presetFileInputReference = useRef(null);
const pendingFilePickerReference = useRef(null);
const pendingPresetPickerReference = useRef(null);
- const geometryCacheReference = useRef>(
- new Map([['torusKnot', activeGeometry]]),
- );
- const lastSuccessfulShapeReference = useRef(state.settings.shapeKey);
const exportPoseReference = useRef(
createInitialExportPose(),
);
@@ -400,6 +230,8 @@ export function HalftoneStudio() {
);
}, [timeoutRegistry]);
+ const imageElement = useImageElement(imageFile);
+
const selectedShape = useMemo(
() =>
state.geometrySpecs.find(
@@ -407,6 +239,26 @@ export function HalftoneStudio() {
),
[state.geometrySpecs, state.settings.shapeKey],
);
+
+ const geometryLoaderCallbacks = useMemo(
+ () => ({
+ onLoadStart: (label: string) =>
+ dispatch({ type: 'setStatus', message: `Loading ${label}…` }),
+ onLoadSuccess: () => dispatch({ type: 'clearStatus' }),
+ onLoadError: (message: string) =>
+ dispatch({ type: 'setStatus', message, isError: true }),
+ onFallbackShape: (key: string) =>
+ dispatch({ type: 'setShapeKey', value: key }),
+ }),
+ [],
+ );
+
+ const activeGeometry = useGeometryLoader(
+ selectedShape,
+ state.importedFiles,
+ geometryLoaderCallbacks,
+ );
+
const shapeOptions = useMemo(
() =>
state.geometrySpecs.map((shape) => ({
@@ -485,67 +337,6 @@ export function HalftoneStudio() {
};
}, [state.settings.halftone, state.settings.sourceMode]);
- const hashHydratedReference = useRef(false);
- useEffect(() => {
- if (hashHydratedReference.current) {
- return;
- }
-
- hashHydratedReference.current = true;
-
- const decoded = decodeShareState(window.location.hash);
-
- if (!decoded) {
- return;
- }
-
- dispatch({ type: 'replaceSettings', value: decoded.settings });
- setPreviewDistance(decoded.previewDistance);
- setExportName(decoded.exportName);
- }, []);
-
- const hashSyncInitializedReference = useRef(false);
- useEffect(() => {
- if (!hashSyncInitializedReference.current) {
- hashSyncInitializedReference.current = true;
- return;
- }
-
- const timeout = window.setTimeout(() => {
- const hash = encodeShareState({
- settings: state.settings,
- previewDistance,
- exportName,
- });
- const nextUrl = `${window.location.pathname}${window.location.search}#${hash}`;
- window.history.replaceState(null, '', nextUrl);
- }, 250);
-
- return () => window.clearTimeout(timeout);
- }, [state.settings, previewDistance, exportName]);
-
- const handleCopyShareLink = useCallback(() => {
- const url = buildShareUrl({
- settings: state.settings,
- previewDistance,
- exportName,
- });
-
- void navigator.clipboard
- .writeText(url)
- .then(() => {
- dispatch({ type: 'setStatus', message: 'Link copied to clipboard.' });
- clearStatusLater();
- })
- .catch(() => {
- dispatch({
- type: 'setStatus',
- message: 'Could not copy the share link.',
- isError: true,
- });
- });
- }, [clearStatusLater, exportName, previewDistance, state.settings]);
-
const handleCopyHalftoneImage = useCallback(
async (width: number, height: number) => {
const snapshotFn = snapshotReference.current;
@@ -602,37 +393,6 @@ export function HalftoneStudio() {
[clearStatusLater, exportBackground, state.settings.background.color],
);
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- const isCopyShortcut =
- (event.metaKey || event.ctrlKey) &&
- !event.shiftKey &&
- !event.altKey &&
- event.key.toLowerCase() === 'c';
-
- if (!isCopyShortcut) {
- return;
- }
-
- if (isEditablePasteTarget(event.target) || hasTextSelection()) {
- return;
- }
-
- const canvasLayer = canvasLayerReference.current;
- const width = Math.max(canvasLayer?.clientWidth ?? 0, 1);
- const height = Math.max(canvasLayer?.clientHeight ?? 0, 1);
-
- event.preventDefault();
- void handleCopyHalftoneImage(width, height);
- };
-
- window.addEventListener('keydown', handleKeyDown);
-
- return () => {
- window.removeEventListener('keydown', handleKeyDown);
- };
- }, [handleCopyHalftoneImage]);
-
const openFilePicker = useCallback(
(accept: string) => {
return new Promise((resolve) => {
@@ -829,47 +589,6 @@ export function HalftoneStudio() {
[state.settings.halftone, state.settings.sourceMode],
);
- useEffect(() => {
- const handlePaste = (event: ClipboardEvent) => {
- if (isEditablePasteTarget(event.target)) {
- return;
- }
-
- const imageItem = Array.from(event.clipboardData?.items ?? []).find(
- (item) => item.type.startsWith('image/'),
- );
-
- if (!imageItem) {
- return;
- }
-
- const pastedFile = imageItem.getAsFile();
-
- if (!pastedFile) {
- dispatch({
- type: 'setStatus',
- message: 'Could not read the pasted image.',
- isError: true,
- });
- return;
- }
-
- event.preventDefault();
- activateUploadedImage(createPastedImageFile(pastedFile));
- dispatch({
- type: 'setStatus',
- message: 'Image pasted from clipboard.',
- });
- clearStatusLater();
- };
-
- window.addEventListener('paste', handlePaste);
-
- return () => {
- window.removeEventListener('paste', handlePaste);
- };
- }, [activateUploadedImage, clearStatusLater]);
-
const handleUploadSource = useCallback(async () => {
const file = await openFilePicker(
'.fbx,.glb,.png,.jpg,.jpeg,.webp,.gif,.svg',
@@ -1337,96 +1056,7 @@ export function HalftoneStudio() {
state.settings,
]);
- useEffect(() => {
- void loadDefaultImageFile()
- .then((file) => {
- setImageFile((currentFile) => currentFile ?? file);
- })
- .catch((error) => {
- console.error(error);
- });
- }, [loadDefaultImageFile]);
-
- useEffect(() => {
- if (!imageFile) {
- setImageElement(null);
- return;
- }
-
- const url = URL.createObjectURL(imageFile);
- const img = new Image();
- img.onload = () => setImageElement(img);
- img.src = url;
-
- return () => URL.revokeObjectURL(url);
- }, [imageFile]);
-
- useEffect(() => {
- const geometryCache = geometryCacheReference.current;
-
- return () => {
- disposeGeometryCache(geometryCache);
- };
- }, []);
-
- useEffect(() => {
- if (!selectedShape) {
- return;
- }
-
- let cancelled = false;
-
- if (selectedShape.kind === 'imported') {
- dispatch({
- type: 'setStatus',
- message: `Loading ${selectedShape.label}…`,
- });
- } else {
- dispatch({ type: 'clearStatus' });
- }
-
- void getGeometryForSpec(
- selectedShape,
- state.importedFiles[selectedShape.key],
- geometryCacheReference.current,
- )
- .then((geometry) => {
- if (cancelled) {
- return;
- }
-
- lastSuccessfulShapeReference.current = selectedShape.key;
- setActiveGeometry(geometry);
- dispatch({ type: 'clearStatus' });
- })
- .catch((error) => {
- if (cancelled) {
- return;
- }
-
- console.error(error);
-
- dispatch({
- type: 'setStatus',
- message:
- error instanceof Error
- ? error.message
- : `${selectedShape.label} failed to load.`,
- isError: true,
- });
-
- if (lastSuccessfulShapeReference.current !== selectedShape.key) {
- dispatch({
- type: 'setShapeKey',
- value: lastSuccessfulShapeReference.current,
- });
- }
- });
-
- return () => {
- cancelled = true;
- };
- }, [selectedShape, state.importedFiles]);
+ useDefaultHalftoneImage({ loadDefaultImageFile, setImageFile });
const background = state.settings.background.color;
const backgroundTone = isLightColor(background) ? 'light' : 'dark';
@@ -1434,8 +1064,40 @@ export function HalftoneStudio() {
dispatch({ type: 'hideHint' });
}, []);
+ const handlePasteImage = useCallback(
+ (file: File) => {
+ activateUploadedImage(file);
+ dispatch({ type: 'setStatus', message: 'Image pasted from clipboard.' });
+ clearStatusLater();
+ },
+ [activateUploadedImage, clearStatusLater],
+ );
+
+ const handlePasteError = useCallback(() => {
+ dispatch({
+ type: 'setStatus',
+ message: 'Could not read the pasted image.',
+ isError: true,
+ });
+ }, []);
+
+ const handleCopyShortcut = useCallback(
+ (width: number, height: number) => {
+ void handleCopyHalftoneImage(width, height);
+ },
+ [handleCopyHalftoneImage],
+ );
+
return (
+
+
dispatch({ type: 'patchAnimation', value })
}
- onCopyShareLink={handleCopyShareLink}
onDashColorChange={(value) =>
dispatch({
type: 'patchHalftone',
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/AnimationsTab.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/AnimationsTab.tsx
index e1e281122a..8d0b61722d 100644
--- a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/AnimationsTab.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/AnimationsTab.tsx
@@ -4,8 +4,8 @@ import {
formatAngle,
formatDecimal,
formatPercent,
-} from '@/app/[locale]/halftone/_lib/formatters';
-import type { HalftoneStudioSettings } from '@/lib/halftone/state';
+} from '@/lib/halftone/utils/formatters';
+import type { HalftoneStudioSettings } from '@/lib/halftone/utils/state';
import {
ColorControlLabel,
ColorControlRow,
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ColorField.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ColorField.tsx
new file mode 100644
index 0000000000..41b1a4180e
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ColorField.tsx
@@ -0,0 +1,171 @@
+'use client';
+
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+import { useEffect, useRef, useState } from 'react';
+
+import { TAB_LABEL_WIDTH } from './controls-form-constants';
+
+export const ColorControlRow = styled.div`
+ align-items: center;
+ display: grid;
+ gap: 10px;
+ grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr);
+`;
+
+export const ColorControlLabel = styled.span`
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 11px;
+`;
+
+export const ColorPickerField = styled.div`
+ align-items: center;
+ cursor: text;
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ justify-self: end;
+ min-height: 16px;
+ min-width: fit-content;
+ padding: 0;
+ position: relative;
+ width: auto;
+`;
+
+export const ColorSwatch = styled.span`
+ border: 1px solid rgba(255, 255, 255, 0.16);
+ border-radius: 4px;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
+ flex-shrink: 0;
+ height: 16px;
+ width: 16px;
+`;
+
+export const ColorHexInput = styled.input`
+ appearance: none;
+ background: transparent;
+ border: none;
+ color: rgba(255, 255, 255, 0.85);
+ font-family: ${theme.font.family.mono};
+ font-size: 11px;
+ height: 16px;
+ letter-spacing: 0.02em;
+ line-height: 16px;
+ min-width: 7ch;
+ outline: none;
+ padding: 0;
+ text-align: right;
+ text-transform: uppercase;
+ width: 7ch;
+
+ &::placeholder {
+ color: rgba(255, 255, 255, 0.32);
+ }
+`;
+
+export const ColorSwatchButton = styled.label`
+ align-items: center;
+ cursor: pointer;
+ display: inline-flex;
+ flex-shrink: 0;
+ height: 16px;
+ justify-content: center;
+ position: relative;
+`;
+
+export const ColorInput = styled.input`
+ appearance: none;
+ border: none;
+ cursor: pointer;
+ height: 100%;
+ inset: 0;
+ outline: none;
+ opacity: 0;
+ padding: 0;
+ position: absolute;
+ width: 100%;
+`;
+
+function normalizeHexColor(value: string): string | null {
+ const normalizedValue = value.trim().replace(/^#/, '');
+
+ if (!/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(normalizedValue)) {
+ return null;
+ }
+
+ return `#${normalizedValue.toUpperCase()}`;
+}
+
+type ColorFieldProps = {
+ ariaLabel: string;
+ onChange: (value: string) => void;
+ value: string;
+};
+
+export function ColorField({ ariaLabel, onChange, value }: ColorFieldProps) {
+ const normalizedValue = value.toUpperCase();
+ const [draftValue, setDraftValue] = useState(normalizedValue);
+ const escapePressedReference = useRef(false);
+
+ useEffect(() => {
+ setDraftValue(normalizedValue);
+ }, [normalizedValue]);
+
+ const commitDraftValue = () => {
+ if (escapePressedReference.current) {
+ escapePressedReference.current = false;
+ return;
+ }
+
+ const nextValue = normalizeHexColor(draftValue);
+
+ if (!nextValue) {
+ setDraftValue(normalizedValue);
+ return;
+ }
+
+ setDraftValue(nextValue);
+
+ if (nextValue !== normalizedValue) {
+ onChange(nextValue);
+ }
+ };
+
+ return (
+
+ event.currentTarget.select()}
+ onChange={(event) => setDraftValue(event.target.value.toUpperCase())}
+ onFocus={(event) => event.currentTarget.select()}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ event.currentTarget.blur();
+ }
+
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ escapePressedReference.current = true;
+ setDraftValue(normalizedValue);
+ event.currentTarget.blur();
+ }
+ }}
+ spellCheck={false}
+ type="text"
+ value={draftValue}
+ />
+
+
+ onChange(event.target.value)}
+ type="color"
+ value={value}
+ />
+
+
+ );
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsPanelShell.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsPanelShell.tsx
new file mode 100644
index 0000000000..d134655387
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsPanelShell.tsx
@@ -0,0 +1,188 @@
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+
+import { TAB_LABEL_WIDTH } from './controls-form-constants';
+
+export const PanelShell = styled.aside<{ $collapsed?: boolean }>`
+ background: rgba(18, 18, 22, 0.88);
+ /* -webkit- prefix is required for the blur to render on Safari < 18. */
+ -webkit-backdrop-filter: blur(24px) saturate(1.4);
+ backdrop-filter: blur(24px) saturate(1.4);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 16px;
+ box-shadow:
+ 0 24px 64px rgba(0, 0, 0, 0.4),
+ 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
+ color: rgba(255, 255, 255, 0.7);
+ display: flex;
+ flex-direction: column;
+ font-family: ${theme.font.family.sans};
+ font-size: 11px;
+ height: ${(props) => (props.$collapsed ? 'auto' : '100%')};
+ overflow: hidden;
+ width: ${(props) =>
+ props.$collapsed ? 'auto' : 'min(320px, calc(100vw - 32px))'};
+
+ @media (max-width: ${theme.breakpoints.md - 1}px) {
+ height: ${(props) => (props.$collapsed ? 'auto' : '100%')};
+ width: ${(props) => (props.$collapsed ? 'auto' : '100%')};
+ }
+`;
+
+export const ControlGrid = styled.div`
+ display: grid;
+ gap: 10px;
+`;
+
+export const ValueDisplay = styled.div`
+ align-items: center;
+ background-color: rgba(255, 255, 255, 0.07);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.85);
+ display: flex;
+ font-family: ${theme.font.family.sans};
+ font-size: 11px;
+ min-height: 31px;
+ overflow: hidden;
+ padding: 7px 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 100%;
+`;
+
+export const ShapeRow = styled.div`
+ align-items: center;
+ display: grid;
+ gap: 10px;
+ grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr) auto;
+`;
+
+export const UploadButton = styled.button`
+ align-items: center;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.5);
+ cursor: pointer;
+ display: flex;
+ flex-shrink: 0;
+ font-size: 13px;
+ height: 24px;
+ justify-content: center;
+ transition: all 0.15s ease;
+ width: 24px;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.22);
+ color: rgba(255, 255, 255, 0.8);
+ }
+`;
+
+export const ExportNameInput = styled.input`
+ background: rgba(255, 255, 255, 0.07);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.85);
+ font-family: ${theme.font.family.sans};
+ font-size: 11px;
+ margin-bottom: 14px;
+ outline: none;
+ padding: 7px 10px;
+ transition: border-color 0.15s ease;
+ width: 100%;
+
+ &:hover {
+ border-color: rgba(255, 255, 255, 0.25);
+ }
+
+ &:focus {
+ border-color: #4a38f5;
+ }
+
+ &::placeholder {
+ color: rgba(255, 255, 255, 0.3);
+ }
+`;
+
+export const ExportPreview = styled.div`
+ background: rgba(0, 0, 0, 0.4);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 10px;
+ color: rgba(255, 255, 255, 0.55);
+ font-family: ${theme.font.family.mono};
+ font-size: 10px;
+ line-height: 1.5;
+ margin-bottom: 14px;
+ max-height: 200px;
+ overflow-y: auto;
+ padding: 12px;
+`;
+
+export const ExportButton = styled.button<{ $primary?: boolean }>`
+ align-items: center;
+ background: ${(props) =>
+ props.$primary ? 'rgba(255, 255, 255, 0.24)' : 'rgba(255, 255, 255, 0.2)'};
+ border: none;
+ border-radius: 8px;
+ color: ${(props) =>
+ props.$primary ? 'rgba(255, 255, 255, 0.92)' : 'rgba(255, 255, 255, 0.8)'};
+ cursor: pointer;
+ display: flex;
+ font-family: ${theme.font.family.sans};
+ font-size: 11px;
+ font-weight: ${theme.font.weight.medium};
+ gap: 8px;
+ justify-content: center;
+ line-height: normal;
+ margin-top: ${(props) => (props.$primary ? '0' : '8px')};
+ padding: 7px 12px;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease,
+ transform 0.15s ease;
+ width: 100%;
+
+ &:hover {
+ background: ${(props) =>
+ props.$primary
+ ? 'rgba(255, 255, 255, 0.28)'
+ : 'rgba(255, 255, 255, 0.24)'};
+ color: rgba(255, 255, 255, 0.92);
+ }
+
+ &:active {
+ background: ${(props) =>
+ props.$primary
+ ? 'rgba(255, 255, 255, 0.3)'
+ : 'rgba(255, 255, 255, 0.28)'};
+ transform: translateY(1px);
+ }
+
+ &:focus-visible {
+ outline: 1px solid rgba(255, 255, 255, 0.36);
+ outline-offset: 2px;
+ }
+
+ &:disabled {
+ background: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.36);
+ cursor: not-allowed;
+ transform: none;
+ }
+`;
+
+export const ExportNote = styled.div`
+ color: rgba(255, 255, 255, 0.3);
+ font-size: 10px;
+ line-height: 1.5;
+ margin-top: 12px;
+`;
+
+export const SmallBody = styled.p`
+ color: rgba(255, 255, 255, 0.45);
+ font-size: 10px;
+ line-height: 1.6;
+ margin-bottom: 14px;
+`;
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsSections.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsSections.tsx
new file mode 100644
index 0000000000..ea6b024d69
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsSections.tsx
@@ -0,0 +1,64 @@
+import { styled } from '@linaria/react';
+import { type ChangeEventHandler, type ReactNode, useId } from 'react';
+
+import { Toggle, ToggleInput, ToggleTrack } from './ToggleControl';
+
+export const Section = styled.section<{ $first?: boolean }>`
+ border-top: ${(props) =>
+ props.$first ? 'none' : '1px solid rgba(255, 255, 255, 0.06)'};
+ margin-top: ${(props) => (props.$first ? '0' : '14px')};
+ padding-top: ${(props) => (props.$first ? '0' : '14px')};
+`;
+
+export const SectionTitle = styled.div<{ $preserveCase?: boolean }>`
+ color: rgba(255, 255, 255, 0.36);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: ${(props) => (props.$preserveCase ? '0.02em' : '0.08em')};
+ margin-bottom: 10px;
+ text-transform: ${(props) => (props.$preserveCase ? 'none' : 'uppercase')};
+`;
+
+export const SectionHeader = styled.div`
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 10px;
+
+ & > ${SectionTitle} {
+ margin-bottom: 0;
+ }
+`;
+
+type SectionToggleHeaderProps = {
+ checked: boolean;
+ children: ReactNode;
+ onChange: ChangeEventHandler;
+ preserveCase?: boolean;
+};
+
+export function SectionToggleHeader({
+ checked,
+ children,
+ onChange,
+ preserveCase,
+}: SectionToggleHeaderProps) {
+ const titleId = useId();
+
+ return (
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsTabs.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsTabs.tsx
new file mode 100644
index 0000000000..06eedfbc88
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ControlsTabs.tsx
@@ -0,0 +1,50 @@
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+
+export const TabsBar = styled.div<{ $collapsed?: boolean }>`
+ align-items: center;
+ display: flex;
+ flex-shrink: 0;
+ gap: 6px;
+ margin: 0;
+ padding: ${(props) => (props.$collapsed ? '12px' : '12px 12px 0')};
+`;
+
+export const TabButton = styled.button<{ $active: boolean }>`
+ background: ${(props) =>
+ props.$active ? 'rgba(255, 255, 255, 0.08)' : 'transparent'};
+ border: none;
+ border-radius: 7px;
+ color: ${(props) =>
+ props.$active ? 'rgba(255, 255, 255, 0.94)' : 'rgba(255, 255, 255, 0.52)'};
+ cursor: pointer;
+ font-family: ${theme.font.family.sans};
+ font-size: 12px;
+ font-weight: ${(props) => (props.$active ? 600 : 500)};
+ letter-spacing: 0;
+ line-height: 1;
+ padding: 8px 10px;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
+
+ &:hover {
+ background: ${(props) =>
+ props.$active ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.04)'};
+ color: ${(props) =>
+ props.$active
+ ? 'rgba(255, 255, 255, 0.94)'
+ : 'rgba(255, 255, 255, 0.74)'};
+ }
+
+ &:focus-visible {
+ outline: 1px solid rgba(255, 255, 255, 0.35);
+ outline-offset: 1px;
+ }
+`;
+
+export const TabContent = styled.div`
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+`;
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/DesignTab.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/DesignTab.tsx
index cd11a060ec..d4cc8ad83e 100644
--- a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/DesignTab.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/DesignTab.tsx
@@ -1,9 +1,6 @@
'use client';
-import {
- formatAngle,
- formatDecimal,
-} from '@/app/[locale]/halftone/_lib/formatters';
+import { formatAngle, formatDecimal } from '@/lib/halftone/utils/formatters';
import {
DEFAULT_GLASS_ANIMATION_SETTINGS,
DEFAULT_GLASS_LIGHTING_SETTINGS,
@@ -14,7 +11,7 @@ import {
type HalftoneBackgroundSettings,
type HalftoneSourceMode,
type HalftoneStudioSettings,
-} from '@/lib/halftone/state';
+} from '@/lib/halftone/utils/state';
import { styled } from '@linaria/react';
import {
ColorControlLabel,
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ExportTab.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ExportTab.tsx
index d35b66ffa8..26dcf2fef5 100644
--- a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ExportTab.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ExportTab.tsx
@@ -1,12 +1,12 @@
'use client';
-import type { ReactExportSettings } from '@/app/[locale]/halftone/_lib/exporters';
-import { resolveExportArtifactNames } from '@/app/[locale]/halftone/_lib/exportNames';
-import { formatAnimationName } from '@/app/[locale]/halftone/_lib/formatters';
+import type { ReactExportSettings } from '@/lib/halftone/utils/exporters';
+import { resolveExportArtifactNames } from '@/lib/halftone/utils/export-names';
+import { formatAnimationName } from '@/lib/halftone/utils/formatters';
import type {
HalftoneGeometrySpec,
HalftoneStudioSettings,
-} from '@/lib/halftone/state';
+} from '@/lib/halftone/utils/state';
import { useState } from 'react';
import {
ExportButton,
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/LabelWithTooltip.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/LabelWithTooltip.tsx
new file mode 100644
index 0000000000..d673f066f0
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/LabelWithTooltip.tsx
@@ -0,0 +1,153 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+
+import { TooltipPositionEffect } from './effect-components/TooltipPositionEffect';
+
+const LabelWithInfoRow = styled.span`
+ align-items: center;
+ display: inline-flex;
+ gap: 6px;
+ min-width: 0;
+`;
+
+const InfoTooltipRoot = styled.span`
+ display: inline-flex;
+ position: relative;
+`;
+
+const InfoTooltipButton = styled.button`
+ align-items: center;
+ background: transparent;
+ border: none;
+ color: rgba(255, 255, 255, 0.32);
+ cursor: help;
+ display: inline-flex;
+ height: 14px;
+ justify-content: center;
+ margin: 0;
+ padding: 0;
+ transition: color 0.15s ease;
+ width: 14px;
+
+ &:hover,
+ &:focus-visible {
+ color: rgba(255, 255, 255, 0.7);
+ outline: none;
+ }
+
+ &:hover + span,
+ &:focus-visible + span {
+ opacity: 1;
+ transform: translate(-50%, 0);
+ }
+`;
+
+const InfoTooltipBubble = styled.span`
+ background: rgba(9, 9, 13, 0.96);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 10px;
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
+ color: rgba(255, 255, 255, 0.78);
+ font-size: 10px;
+ line-height: 1.45;
+ max-width: 220px;
+ padding: 8px 10px;
+ pointer-events: none;
+ position: fixed;
+ width: max-content;
+ z-index: 4;
+
+ &::before {
+ background: rgba(9, 9, 13, 0.96);
+ border-left: 1px solid rgba(255, 255, 255, 0.08);
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ content: '';
+ height: 8px;
+ left: 50%;
+ position: absolute;
+ top: -5px;
+ transform: translateX(-50%) rotate(45deg);
+ width: 8px;
+ }
+`;
+
+type LabelWithTooltipProps = {
+ description: string;
+ label: ReactNode;
+};
+
+export function LabelWithTooltip({
+ description,
+ label,
+}: LabelWithTooltipProps) {
+ const buttonReference = useRef(null);
+ const tooltipId = useId();
+ const [isMounted, setIsMounted] = useState(false);
+ const [isOpen, setIsOpen] = useState(false);
+ const [tooltipPosition, setTooltipPosition] = useState({
+ left: 0,
+ top: 0,
+ });
+
+ useEffect(() => {
+ setIsMounted(true);
+ }, []);
+
+ return (
+
+
+ {label}
+
+ setIsOpen(false)}
+ onFocus={() => setIsOpen(true)}
+ onMouseEnter={() => setIsOpen(true)}
+ onMouseLeave={() => setIsOpen(false)}
+ ref={buttonReference}
+ type="button"
+ >
+
+
+
+
+
+
+ {isMounted && isOpen
+ ? createPortal(
+
+ {description}
+ ,
+ document.body,
+ )
+ : null}
+
+
+ );
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SegmentedControl.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SegmentedControl.tsx
new file mode 100644
index 0000000000..8e54cd9740
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SegmentedControl.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+import { useRef, type ReactNode } from 'react';
+
+import { TAB_LABEL_WIDTH } from './controls-form-constants';
+
+export const SegmentedLabel = styled.div`
+ align-items: center;
+ display: grid;
+ gap: 10px;
+ grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr);
+`;
+
+const SegmentedGroup = styled.div`
+ align-items: stretch;
+ background: rgba(255, 255, 255, 0.07);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ display: grid;
+ gap: 1px;
+ grid-auto-columns: minmax(0, 1fr);
+ grid-auto-flow: column;
+ height: 24px;
+ padding: 1px;
+ width: 100%;
+`;
+
+const SegmentedButton = styled.button<{ $active: boolean }>`
+ background: ${(props) =>
+ props.$active ? 'rgba(255, 255, 255, 0.14)' : 'transparent'};
+ border: none;
+ border-radius: 6px;
+ color: ${(props) =>
+ props.$active ? 'rgba(255, 255, 255, 0.94)' : 'rgba(255, 255, 255, 0.58)'};
+ cursor: pointer;
+ font-family: ${theme.font.family.sans};
+ font-size: 11px;
+ font-weight: ${(props) => (props.$active ? 600 : 500)};
+ height: 100%;
+ padding: 0 10px;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
+
+ &:hover {
+ color: rgba(255, 255, 255, 0.86);
+ }
+
+ &:focus-visible {
+ outline: 1px solid rgba(255, 255, 255, 0.35);
+ outline-offset: 1px;
+ }
+`;
+
+type SegmentedControlProps = {
+ children: ReactNode;
+ onChange: (value: string) => void;
+ value: string;
+ options: Array<{ label: string; value: string }>;
+};
+
+export function SegmentedControl({
+ children,
+ onChange,
+ options,
+ value,
+}: SegmentedControlProps) {
+ const groupReference = useRef(null);
+
+ const handleKeyDown = (event: React.KeyboardEvent, currentIndex: number) => {
+ let nextIndex: number | null = null;
+
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
+ nextIndex = (currentIndex + 1) % options.length;
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
+ nextIndex = (currentIndex - 1 + options.length) % options.length;
+ }
+
+ if (nextIndex === null) {
+ return;
+ }
+
+ event.preventDefault();
+ onChange(options[nextIndex].value);
+
+ const buttons =
+ groupReference.current?.querySelectorAll(
+ '[role="radio"]',
+ );
+ buttons?.[nextIndex]?.focus();
+ };
+
+ return (
+
+ {children}
+
+ {options.map((option, index) => {
+ const isActive = option.value === value;
+
+ return (
+ {
+ if (!isActive) {
+ onChange(option.value);
+ }
+ }}
+ onKeyDown={(event) => handleKeyDown(event, index)}
+ role="radio"
+ tabIndex={isActive ? 0 : -1}
+ type="button"
+ >
+ {option.label}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SelectControl.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SelectControl.tsx
new file mode 100644
index 0000000000..f0ae5eb101
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SelectControl.tsx
@@ -0,0 +1,73 @@
+'use client';
+
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+import type { ChangeEventHandler, ReactNode } from 'react';
+
+import { TAB_LABEL_WIDTH } from './controls-form-constants';
+
+export const SelectLabel = styled.label`
+ align-items: center;
+ cursor: pointer;
+ display: grid;
+ gap: 10px;
+ grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr);
+`;
+
+export const SelectInput = styled.select`
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ background-color: rgba(255, 255, 255, 0.07);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.52)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+ background-position: right 12px center;
+ background-repeat: no-repeat;
+ background-size: 12px 12px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.85);
+ cursor: pointer;
+ font-family: ${theme.font.family.sans};
+ font-size: 11px;
+ height: 24px;
+ outline: none;
+ padding: 0 34px 0 10px;
+ transition: border-color 0.15s ease;
+ width: 100%;
+
+ &:hover {
+ border-color: rgba(255, 255, 255, 0.25);
+ }
+
+ &:focus-visible {
+ border-color: rgba(255, 255, 255, 0.4);
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
+ }
+`;
+
+type SelectControlProps = {
+ children: ReactNode;
+ onChange: ChangeEventHandler;
+ value: string;
+ options: Array<{ label: string; value: string }>;
+};
+
+export function SelectControl({
+ children,
+ onChange,
+ options,
+ value,
+}: SelectControlProps) {
+ return (
+
+ {children}
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SliderControl.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SliderControl.tsx
new file mode 100644
index 0000000000..4e8c2052b0
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/SliderControl.tsx
@@ -0,0 +1,279 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import {
+ useEffect,
+ useRef,
+ useState,
+ type ChangeEventHandler,
+ type ReactNode,
+} from 'react';
+
+import { TAB_LABEL_WIDTH } from './controls-form-constants';
+
+export const SliderLabel = styled.label`
+ align-items: center;
+ cursor: pointer;
+ display: grid;
+ gap: 10px;
+ grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr) auto;
+`;
+
+export const ControlValue = styled.span`
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+ min-width: 34px;
+ text-align: right;
+`;
+
+export const SliderInput = styled.input<{ $fillPercent: number }>`
+ appearance: none;
+ background: transparent;
+ cursor: pointer;
+ height: 16px;
+ outline: none;
+ transition: transform 0.2s ease;
+ width: 100%;
+ -webkit-tap-highlight-color: transparent;
+
+ &::-webkit-slider-runnable-track {
+ background: linear-gradient(
+ to right,
+ rgba(255, 255, 255, 0.35) ${({ $fillPercent }) => $fillPercent}%,
+ rgba(255, 255, 255, 0.08) ${({ $fillPercent }) => $fillPercent}%
+ );
+ border-radius: 999px;
+ height: 6px;
+ }
+
+ &::-webkit-slider-thumb {
+ appearance: none;
+ background: transparent;
+ border: none;
+ border-radius: 999px;
+ height: 16px;
+ margin-top: -5px;
+ width: 16px;
+ }
+
+ &::-moz-range-progress {
+ background: rgba(255, 255, 255, 0.35);
+ border-radius: 999px;
+ height: 6px;
+ }
+
+ &::-moz-range-thumb {
+ background: transparent;
+ border: none;
+ border-radius: 999px;
+ height: 16px;
+ width: 16px;
+ }
+
+ &::-moz-range-track {
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 999px;
+ height: 6px;
+ }
+
+ &:active {
+ transform: scaleY(2);
+ }
+`;
+
+const EditableControlValueButton = styled.button`
+ background: transparent;
+ border: none;
+ color: rgba(255, 255, 255, 0.5);
+ cursor: text;
+ font-family: inherit;
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+ min-width: 34px;
+ padding: 0;
+ text-align: right;
+
+ &:focus-visible {
+ outline: 1px solid rgba(255, 255, 255, 0.25);
+ outline-offset: 2px;
+ }
+`;
+
+const EditableControlValueInput = styled.input`
+ appearance: textfield;
+ background: transparent;
+ border: none;
+ color: rgba(255, 255, 255, 0.62);
+ font-family: inherit;
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+ min-width: 34px;
+ outline: none;
+ padding: 0;
+ text-align: right;
+
+ &::-webkit-outer-spin-button,
+ &::-webkit-inner-spin-button {
+ appearance: none;
+ margin: 0;
+ }
+`;
+
+function getStepPrecision(step: number | undefined) {
+ if (!step || Number.isInteger(step)) {
+ return 0;
+ }
+
+ const stepString = step.toString().toLowerCase();
+
+ if (stepString.includes('e-')) {
+ const exponent = stepString.split('e-')[1];
+
+ return exponent ? Number.parseInt(exponent, 10) : 0;
+ }
+
+ return stepString.split('.')[1]?.length ?? 0;
+}
+
+function formatEditableValue(value: number, step: number | undefined) {
+ const precision = getStepPrecision(step);
+
+ if (precision === 0) {
+ return String(Math.round(value));
+ }
+
+ return String(Number(value.toFixed(precision)));
+}
+
+function clampAndSnapValue(
+ value: number,
+ min: number,
+ max: number,
+ step: number | undefined,
+) {
+ const clampedValue = Math.min(Math.max(value, min), max);
+
+ if (!step || step <= 0) {
+ return clampedValue;
+ }
+
+ const precision = getStepPrecision(step);
+ const snappedValue = Math.round((clampedValue - min) / step) * step + min;
+
+ return Number(Math.min(Math.max(snappedValue, min), max).toFixed(precision));
+}
+
+type SliderControlProps = {
+ children: ReactNode;
+ max: number;
+ min: number;
+ onChange: ChangeEventHandler;
+ step?: number;
+ value: number;
+ valueLabel: string;
+};
+
+export function SliderControl({
+ children,
+ max,
+ min,
+ onChange,
+ step,
+ value,
+ valueLabel,
+}: SliderControlProps) {
+ const [draftValue, setDraftValue] = useState(null);
+ const valueInputReference = useRef(null);
+ const fillPercent = ((value - min) / (max - min)) * 100;
+ const isEditing = draftValue !== null;
+
+ useEffect(() => {
+ if (!isEditing) {
+ return;
+ }
+
+ valueInputReference.current?.focus();
+ valueInputReference.current?.select();
+ }, [isEditing]);
+
+ const commitDraftValue = () => {
+ if (draftValue === null) {
+ return;
+ }
+
+ const trimmed = draftValue.trim();
+ setDraftValue(null);
+
+ if (trimmed === '') {
+ return;
+ }
+
+ const nextValue = Number(trimmed);
+
+ if (!Number.isFinite(nextValue)) {
+ return;
+ }
+
+ const normalizedValue = clampAndSnapValue(nextValue, min, max, step);
+
+ onChange({
+ target: { value: String(normalizedValue) },
+ } as React.ChangeEvent);
+ };
+
+ return (
+
+ {children}
+
+ {isEditing ? (
+ setDraftValue(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ commitDraftValue();
+ return;
+ }
+
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ setDraftValue(null);
+ }
+ }}
+ ref={valueInputReference}
+ step={step}
+ type="number"
+ value={draftValue}
+ />
+ ) : (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ setDraftValue(formatEditableValue(value, step));
+ }}
+ onMouseDown={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ type="button"
+ >
+ {valueLabel}
+
+ )}
+
+ );
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ToggleControl.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ToggleControl.tsx
new file mode 100644
index 0000000000..aa1ad13d5c
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/ToggleControl.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import type { ChangeEventHandler, ReactNode } from 'react';
+
+export const ToggleRow = styled.div`
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ padding: 2px 0;
+`;
+
+export const ToggleText = styled.span`
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 11px;
+`;
+
+export const Toggle = styled.label`
+ display: block;
+ flex-shrink: 0;
+ height: 18px;
+ position: relative;
+ width: 28px;
+`;
+
+export const ToggleInput = styled.input`
+ height: 0;
+ opacity: 0;
+ position: absolute;
+ width: 0;
+
+ &:checked + span {
+ background: #4a38f5;
+ }
+
+ &:checked + span::after {
+ transform: translateX(12px);
+ }
+`;
+
+export const ToggleTrack = styled.span`
+ background: rgba(255, 255, 255, 0.14);
+ border-radius: 999px;
+ cursor: pointer;
+ inset: 0;
+ position: absolute;
+ transition: background 0.2s ease;
+
+ &::after {
+ background: #fff;
+ border-radius: 50%;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
+ content: '';
+ height: 12px;
+ left: 2px;
+ position: absolute;
+ top: 3px;
+ transition: transform 0.2s ease;
+ width: 12px;
+ }
+`;
+
+type ToggleControlProps = {
+ checked: boolean;
+ label: ReactNode;
+ onChange: ChangeEventHandler;
+};
+
+export function ToggleControl({
+ checked,
+ label,
+ onChange,
+}: ToggleControlProps) {
+ return (
+
+ {label}
+
+
+
+
+
+ );
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-form-constants.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-form-constants.ts
new file mode 100644
index 0000000000..f0c3e62512
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-form-constants.ts
@@ -0,0 +1 @@
+export const TAB_LABEL_WIDTH = 72;
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-ui.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-ui.ts
new file mode 100644
index 0000000000..a179441244
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-ui.ts
@@ -0,0 +1,56 @@
+export { TAB_LABEL_WIDTH } from './controls-form-constants';
+
+export {
+ ColorControlLabel,
+ ColorControlRow,
+ ColorField,
+ ColorHexInput,
+ ColorInput,
+ ColorPickerField,
+ ColorSwatch,
+ ColorSwatchButton,
+} from './ColorField';
+
+export { LabelWithTooltip } from './LabelWithTooltip';
+
+export {
+ ControlGrid,
+ ExportButton,
+ ExportNameInput,
+ ExportNote,
+ ExportPreview,
+ PanelShell,
+ ShapeRow,
+ SmallBody,
+ UploadButton,
+ ValueDisplay,
+} from './ControlsPanelShell';
+
+export { SegmentedControl, SegmentedLabel } from './SegmentedControl';
+
+export { SelectControl, SelectInput, SelectLabel } from './SelectControl';
+
+export {
+ ControlValue,
+ SliderControl,
+ SliderInput,
+ SliderLabel,
+} from './SliderControl';
+
+export { TabButton, TabContent, TabsBar } from './ControlsTabs';
+
+export {
+ Toggle,
+ ToggleControl,
+ ToggleInput,
+ ToggleRow,
+ ToggleText,
+ ToggleTrack,
+} from './ToggleControl';
+
+export {
+ Section,
+ SectionHeader,
+ SectionTitle,
+ SectionToggleHeader,
+} from './ControlsSections';
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-ui.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-ui.tsx
deleted file mode 100644
index ed947b2158..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/controls-ui.tsx
+++ /dev/null
@@ -1,1120 +0,0 @@
-'use client';
-
-import { theme } from '@/theme';
-import { styled } from '@linaria/react';
-import {
- useEffect,
- useId,
- useRef,
- useState,
- type ChangeEventHandler,
- type ReactNode,
-} from 'react';
-import { createPortal } from 'react-dom';
-
-const TAB_LABEL_WIDTH = 72;
-
-export const PanelShell = styled.aside<{ $collapsed?: boolean }>`
- background: rgba(18, 18, 22, 0.88);
- /* -webkit- prefix is required for the blur to render on Safari < 18. */
- -webkit-backdrop-filter: blur(24px) saturate(1.4);
- backdrop-filter: blur(24px) saturate(1.4);
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 16px;
- box-shadow:
- 0 24px 64px rgba(0, 0, 0, 0.4),
- 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
- color: rgba(255, 255, 255, 0.7);
- display: flex;
- flex-direction: column;
- font-family: ${theme.font.family.sans};
- font-size: 11px;
- height: ${(props) => (props.$collapsed ? 'auto' : '100%')};
- overflow: hidden;
- width: ${(props) =>
- props.$collapsed ? 'auto' : 'min(320px, calc(100vw - 32px))'};
-
- @media (max-width: ${theme.breakpoints.md - 1}px) {
- height: ${(props) => (props.$collapsed ? 'auto' : '100%')};
- width: ${(props) => (props.$collapsed ? 'auto' : '100%')};
- }
-`;
-
-export const TabsBar = styled.div<{ $collapsed?: boolean }>`
- align-items: center;
- display: flex;
- flex-shrink: 0;
- gap: 6px;
- margin: 0;
- padding: ${(props) => (props.$collapsed ? '12px' : '12px 12px 0')};
-`;
-
-export const TabButton = styled.button<{ $active: boolean }>`
- background: ${(props) =>
- props.$active ? 'rgba(255, 255, 255, 0.08)' : 'transparent'};
- border: none;
- border-radius: 7px;
- color: ${(props) =>
- props.$active ? 'rgba(255, 255, 255, 0.94)' : 'rgba(255, 255, 255, 0.52)'};
- cursor: pointer;
- font-family: ${theme.font.family.sans};
- font-size: 12px;
- font-weight: ${(props) => (props.$active ? 600 : 500)};
- letter-spacing: 0;
- line-height: 1;
- padding: 8px 10px;
- transition:
- background-color 0.15s ease,
- color 0.15s ease;
-
- &:hover {
- background: ${(props) =>
- props.$active ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.04)'};
- color: ${(props) =>
- props.$active
- ? 'rgba(255, 255, 255, 0.94)'
- : 'rgba(255, 255, 255, 0.74)'};
- }
-
- &:focus-visible {
- outline: 1px solid rgba(255, 255, 255, 0.35);
- outline-offset: 1px;
- }
-`;
-
-export const TabContent = styled.div`
- flex: 1;
- overflow-y: auto;
- padding: 16px;
-`;
-
-export const Section = styled.section<{ $first?: boolean }>`
- border-top: ${(props) =>
- props.$first ? 'none' : '1px solid rgba(255, 255, 255, 0.06)'};
- margin-top: ${(props) => (props.$first ? '0' : '14px')};
- padding-top: ${(props) => (props.$first ? '0' : '14px')};
-`;
-
-export const SectionTitle = styled.div<{ $preserveCase?: boolean }>`
- color: rgba(255, 255, 255, 0.36);
- font-size: 10px;
- font-weight: 600;
- letter-spacing: ${(props) => (props.$preserveCase ? '0.02em' : '0.08em')};
- margin-bottom: 10px;
- text-transform: ${(props) => (props.$preserveCase ? 'none' : 'uppercase')};
-`;
-
-export const SectionHeader = styled.div`
- align-items: center;
- display: flex;
- justify-content: space-between;
- margin-bottom: 10px;
-
- & > ${SectionTitle} {
- margin-bottom: 0;
- }
-`;
-
-export const ControlGrid = styled.div`
- display: grid;
- gap: 10px;
-`;
-
-export const SliderLabel = styled.label`
- align-items: center;
- cursor: pointer;
- display: grid;
- gap: 10px;
- grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr) auto;
-`;
-
-export const SelectLabel = styled.label`
- align-items: center;
- cursor: pointer;
- display: grid;
- gap: 10px;
- grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr);
-`;
-
-export const SegmentedLabel = styled.div`
- align-items: center;
- display: grid;
- gap: 10px;
- grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr);
-`;
-
-export const ControlValue = styled.span`
- color: rgba(255, 255, 255, 0.5);
- font-size: 10px;
- font-variant-numeric: tabular-nums;
- min-width: 34px;
- text-align: right;
-`;
-
-const SegmentedGroup = styled.div`
- align-items: stretch;
- background: rgba(255, 255, 255, 0.07);
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 8px;
- display: grid;
- gap: 1px;
- grid-auto-columns: minmax(0, 1fr);
- grid-auto-flow: column;
- height: 24px;
- padding: 1px;
- width: 100%;
-`;
-
-const SegmentedButton = styled.button<{ $active: boolean }>`
- background: ${(props) =>
- props.$active ? 'rgba(255, 255, 255, 0.14)' : 'transparent'};
- border: none;
- border-radius: 6px;
- color: ${(props) =>
- props.$active ? 'rgba(255, 255, 255, 0.94)' : 'rgba(255, 255, 255, 0.58)'};
- cursor: pointer;
- font-family: ${theme.font.family.sans};
- font-size: 11px;
- font-weight: ${(props) => (props.$active ? 600 : 500)};
- height: 100%;
- padding: 0 10px;
- transition:
- background-color 0.15s ease,
- color 0.15s ease;
-
- &:hover {
- color: rgba(255, 255, 255, 0.86);
- }
-
- &:focus-visible {
- outline: 1px solid rgba(255, 255, 255, 0.35);
- outline-offset: 1px;
- }
-`;
-
-const EditableControlValueButton = styled.button`
- background: transparent;
- border: none;
- color: rgba(255, 255, 255, 0.5);
- cursor: text;
- font-family: inherit;
- font-size: 10px;
- font-variant-numeric: tabular-nums;
- min-width: 34px;
- padding: 0;
- text-align: right;
-
- &:focus-visible {
- outline: 1px solid rgba(255, 255, 255, 0.25);
- outline-offset: 2px;
- }
-`;
-
-const EditableControlValueInput = styled.input`
- appearance: textfield;
- background: transparent;
- border: none;
- color: rgba(255, 255, 255, 0.62);
- font-family: inherit;
- font-size: 10px;
- font-variant-numeric: tabular-nums;
- min-width: 34px;
- outline: none;
- padding: 0;
- text-align: right;
-
- &::-webkit-outer-spin-button,
- &::-webkit-inner-spin-button {
- appearance: none;
- margin: 0;
- }
-`;
-
-export const SliderInput = styled.input<{ $fillPercent: number }>`
- appearance: none;
- background: transparent;
- cursor: pointer;
- height: 16px;
- outline: none;
- transition: transform 0.2s ease;
- width: 100%;
- -webkit-tap-highlight-color: transparent;
-
- &::-webkit-slider-runnable-track {
- background: linear-gradient(
- to right,
- rgba(255, 255, 255, 0.35) ${({ $fillPercent }) => $fillPercent}%,
- rgba(255, 255, 255, 0.08) ${({ $fillPercent }) => $fillPercent}%
- );
- border-radius: 999px;
- height: 6px;
- }
-
- &::-webkit-slider-thumb {
- appearance: none;
- background: transparent;
- border: none;
- border-radius: 999px;
- height: 16px;
- margin-top: -5px;
- width: 16px;
- }
-
- &::-moz-range-progress {
- background: rgba(255, 255, 255, 0.35);
- border-radius: 999px;
- height: 6px;
- }
-
- &::-moz-range-thumb {
- background: transparent;
- border: none;
- border-radius: 999px;
- height: 16px;
- width: 16px;
- }
-
- &::-moz-range-track {
- background: rgba(255, 255, 255, 0.08);
- border-radius: 999px;
- height: 6px;
- }
-
- &:active {
- transform: scaleY(2);
- }
-`;
-
-export const SelectInput = styled.select`
- -webkit-appearance: none;
- -moz-appearance: none;
- appearance: none;
- background-color: rgba(255, 255, 255, 0.07);
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.52)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
- background-position: right 12px center;
- background-repeat: no-repeat;
- background-size: 12px 12px;
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 8px;
- color: rgba(255, 255, 255, 0.85);
- cursor: pointer;
- font-family: ${theme.font.family.sans};
- font-size: 11px;
- height: 24px;
- outline: none;
- padding: 0 34px 0 10px;
- transition: border-color 0.15s ease;
- width: 100%;
-
- &:hover {
- border-color: rgba(255, 255, 255, 0.25);
- }
-`;
-
-export const ValueDisplay = styled.div`
- align-items: center;
- background-color: rgba(255, 255, 255, 0.07);
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 8px;
- color: rgba(255, 255, 255, 0.85);
- display: flex;
- font-family: ${theme.font.family.sans};
- font-size: 11px;
- min-height: 31px;
- overflow: hidden;
- padding: 7px 10px;
- text-overflow: ellipsis;
- white-space: nowrap;
- width: 100%;
-`;
-
-export const ToggleRow = styled.div`
- align-items: center;
- display: flex;
- justify-content: space-between;
- padding: 2px 0;
-`;
-
-export const ToggleText = styled.span`
- color: rgba(255, 255, 255, 0.7);
- font-size: 11px;
-`;
-
-const LabelWithInfoRow = styled.span`
- align-items: center;
- display: inline-flex;
- gap: 6px;
- min-width: 0;
-`;
-
-const InfoTooltipRoot = styled.span`
- display: inline-flex;
- position: relative;
-`;
-
-const InfoTooltipButton = styled.button`
- align-items: center;
- background: transparent;
- border: none;
- color: rgba(255, 255, 255, 0.32);
- cursor: help;
- display: inline-flex;
- height: 14px;
- justify-content: center;
- margin: 0;
- padding: 0;
- transition: color 0.15s ease;
- width: 14px;
-
- &:hover,
- &:focus-visible {
- color: rgba(255, 255, 255, 0.7);
- outline: none;
- }
-
- &:hover + span,
- &:focus-visible + span {
- opacity: 1;
- transform: translate(-50%, 0);
- }
-`;
-
-const InfoTooltipBubble = styled.span`
- background: rgba(9, 9, 13, 0.96);
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 10px;
- box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
- color: rgba(255, 255, 255, 0.78);
- font-size: 10px;
- line-height: 1.45;
- max-width: 220px;
- padding: 8px 10px;
- pointer-events: none;
- position: fixed;
- width: max-content;
- z-index: 4;
-
- &::before {
- background: rgba(9, 9, 13, 0.96);
- border-left: 1px solid rgba(255, 255, 255, 0.08);
- border-top: 1px solid rgba(255, 255, 255, 0.08);
- content: '';
- height: 8px;
- left: 50%;
- position: absolute;
- top: -5px;
- transform: translateX(-50%) rotate(45deg);
- width: 8px;
- }
-`;
-
-export const Toggle = styled.label`
- display: block;
- flex-shrink: 0;
- height: 18px;
- position: relative;
- width: 28px;
-`;
-
-export const ToggleInput = styled.input`
- height: 0;
- opacity: 0;
- position: absolute;
- width: 0;
-
- &:checked + span {
- background: #4a38f5;
- }
-
- &:checked + span::after {
- transform: translateX(12px);
- }
-`;
-
-export const ToggleTrack = styled.span`
- background: rgba(255, 255, 255, 0.14);
- border-radius: 999px;
- cursor: pointer;
- inset: 0;
- position: absolute;
- transition: background 0.2s ease;
-
- &::after {
- background: #fff;
- border-radius: 50%;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
- content: '';
- height: 12px;
- left: 2px;
- position: absolute;
- top: 3px;
- transition: transform 0.2s ease;
- width: 12px;
- }
-`;
-
-export const ColorControlRow = styled.div`
- align-items: center;
- display: grid;
- gap: 10px;
- grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr);
-`;
-
-export const ColorControlLabel = styled.span`
- color: rgba(255, 255, 255, 0.7);
- font-size: 11px;
-`;
-
-export const ColorPickerField = styled.div`
- align-items: center;
- cursor: text;
- display: flex;
- gap: 8px;
- justify-content: flex-end;
- justify-self: end;
- min-height: 16px;
- min-width: fit-content;
- padding: 0;
- position: relative;
- width: auto;
-`;
-
-export const ColorSwatch = styled.span`
- border: 1px solid rgba(255, 255, 255, 0.16);
- border-radius: 4px;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
- flex-shrink: 0;
- height: 16px;
- width: 16px;
-`;
-
-export const ColorHexInput = styled.input`
- appearance: none;
- background: transparent;
- border: none;
- color: rgba(255, 255, 255, 0.85);
- font-family: ${theme.font.family.mono};
- font-size: 11px;
- height: 16px;
- letter-spacing: 0.02em;
- line-height: 16px;
- min-width: 7ch;
- outline: none;
- padding: 0;
- text-align: right;
- text-transform: uppercase;
- width: 7ch;
-
- &::placeholder {
- color: rgba(255, 255, 255, 0.32);
- }
-`;
-
-export const ColorSwatchButton = styled.label`
- align-items: center;
- cursor: pointer;
- display: inline-flex;
- flex-shrink: 0;
- height: 16px;
- justify-content: center;
- position: relative;
-`;
-
-export const ColorInput = styled.input`
- appearance: none;
- border: none;
- cursor: pointer;
- height: 100%;
- inset: 0;
- outline: none;
- opacity: 0;
- padding: 0;
- position: absolute;
- width: 100%;
-`;
-
-function normalizeHexColor(value: string): string | null {
- const normalizedValue = value.trim().replace(/^#/, '');
-
- if (!/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(normalizedValue)) {
- return null;
- }
-
- return `#${normalizedValue.toUpperCase()}`;
-}
-
-type ColorFieldProps = {
- ariaLabel: string;
- onChange: (value: string) => void;
- value: string;
-};
-
-export function ColorField({ ariaLabel, onChange, value }: ColorFieldProps) {
- const normalizedValue = value.toUpperCase();
- const [draftValue, setDraftValue] = useState(normalizedValue);
-
- useEffect(() => {
- setDraftValue(normalizedValue);
- }, [normalizedValue]);
-
- const commitDraftValue = () => {
- const nextValue = normalizeHexColor(draftValue);
-
- if (!nextValue) {
- setDraftValue(normalizedValue);
- return;
- }
-
- setDraftValue(nextValue);
-
- if (nextValue !== normalizedValue) {
- onChange(nextValue);
- }
- };
-
- return (
-
- event.currentTarget.select()}
- onChange={(event) => setDraftValue(event.target.value.toUpperCase())}
- onFocus={(event) => event.currentTarget.select()}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- event.currentTarget.blur();
- }
-
- if (event.key === 'Escape') {
- event.preventDefault();
- setDraftValue(normalizedValue);
- event.currentTarget.blur();
- }
- }}
- spellCheck={false}
- type="text"
- value={draftValue}
- />
-
-
- onChange(event.target.value)}
- type="color"
- value={value}
- />
-
-
- );
-}
-
-export const ShapeRow = styled.div`
- align-items: center;
- display: grid;
- gap: 10px;
- grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr) auto;
-`;
-
-export const UploadButton = styled.button`
- align-items: center;
- background: rgba(255, 255, 255, 0.06);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 8px;
- color: rgba(255, 255, 255, 0.5);
- cursor: pointer;
- display: flex;
- flex-shrink: 0;
- font-size: 13px;
- height: 24px;
- justify-content: center;
- transition: all 0.15s ease;
- width: 24px;
-
- &:hover {
- background: rgba(255, 255, 255, 0.12);
- border-color: rgba(255, 255, 255, 0.22);
- color: rgba(255, 255, 255, 0.8);
- }
-`;
-
-export const ExportNameInput = styled.input`
- background: rgba(255, 255, 255, 0.07);
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 8px;
- color: rgba(255, 255, 255, 0.85);
- font-family: ${theme.font.family.sans};
- font-size: 11px;
- margin-bottom: 14px;
- outline: none;
- padding: 7px 10px;
- transition: border-color 0.15s ease;
- width: 100%;
-
- &:hover {
- border-color: rgba(255, 255, 255, 0.25);
- }
-
- &:focus {
- border-color: #4a38f5;
- }
-
- &::placeholder {
- color: rgba(255, 255, 255, 0.3);
- }
-`;
-
-export const ExportPreview = styled.div`
- background: rgba(0, 0, 0, 0.4);
- border: 1px solid rgba(255, 255, 255, 0.06);
- border-radius: 10px;
- color: rgba(255, 255, 255, 0.55);
- font-family: ${theme.font.family.mono};
- font-size: 10px;
- line-height: 1.5;
- margin-bottom: 14px;
- max-height: 200px;
- overflow-y: auto;
- padding: 12px;
-`;
-
-export const ExportButton = styled.button<{ $primary?: boolean }>`
- align-items: center;
- background: ${(props) =>
- props.$primary ? 'rgba(255, 255, 255, 0.24)' : 'rgba(255, 255, 255, 0.2)'};
- border: none;
- border-radius: 8px;
- color: ${(props) =>
- props.$primary ? 'rgba(255, 255, 255, 0.92)' : 'rgba(255, 255, 255, 0.8)'};
- cursor: pointer;
- display: flex;
- font-family: ${theme.font.family.sans};
- font-size: 11px;
- font-weight: ${theme.font.weight.medium};
- gap: 8px;
- justify-content: center;
- line-height: normal;
- margin-top: ${(props) => (props.$primary ? '0' : '8px')};
- padding: 7px 12px;
- transition:
- background-color 0.15s ease,
- color 0.15s ease,
- transform 0.15s ease;
- width: 100%;
-
- &:hover {
- background: ${(props) =>
- props.$primary
- ? 'rgba(255, 255, 255, 0.28)'
- : 'rgba(255, 255, 255, 0.24)'};
- color: rgba(255, 255, 255, 0.92);
- }
-
- &:active {
- background: ${(props) =>
- props.$primary
- ? 'rgba(255, 255, 255, 0.3)'
- : 'rgba(255, 255, 255, 0.28)'};
- transform: translateY(1px);
- }
-
- &:focus-visible {
- outline: 1px solid rgba(255, 255, 255, 0.36);
- outline-offset: 2px;
- }
-
- &:disabled {
- background: rgba(255, 255, 255, 0.1);
- color: rgba(255, 255, 255, 0.36);
- cursor: not-allowed;
- transform: none;
- }
-`;
-
-export const ExportNote = styled.div`
- color: rgba(255, 255, 255, 0.3);
- font-size: 10px;
- line-height: 1.5;
- margin-top: 12px;
-`;
-
-export const SmallBody = styled.p`
- color: rgba(255, 255, 255, 0.45);
- font-size: 10px;
- line-height: 1.6;
- margin-bottom: 14px;
-`;
-
-type SliderControlProps = {
- children: ReactNode;
- max: number;
- min: number;
- onChange: ChangeEventHandler;
- step?: number;
- value: number;
- valueLabel: string;
-};
-
-function getStepPrecision(step: number | undefined) {
- if (!step || Number.isInteger(step)) {
- return 0;
- }
-
- const stepString = step.toString().toLowerCase();
-
- if (stepString.includes('e-')) {
- const exponent = stepString.split('e-')[1];
-
- return exponent ? Number.parseInt(exponent, 10) : 0;
- }
-
- return stepString.split('.')[1]?.length ?? 0;
-}
-
-function formatEditableValue(value: number, step: number | undefined) {
- const precision = getStepPrecision(step);
-
- if (precision === 0) {
- return String(Math.round(value));
- }
-
- return String(Number(value.toFixed(precision)));
-}
-
-function clampAndSnapValue(
- value: number,
- min: number,
- max: number,
- step: number | undefined,
-) {
- const clampedValue = Math.min(Math.max(value, min), max);
-
- if (!step || step <= 0) {
- return clampedValue;
- }
-
- const precision = getStepPrecision(step);
- const snappedValue = Math.round((clampedValue - min) / step) * step + min;
-
- return Number(Math.min(Math.max(snappedValue, min), max).toFixed(precision));
-}
-
-export function SliderControl({
- children,
- max,
- min,
- onChange,
- step,
- value,
- valueLabel,
-}: SliderControlProps) {
- const [draftValue, setDraftValue] = useState(null);
- const valueInputReference = useRef(null);
- const fillPercent = ((value - min) / (max - min)) * 100;
- const isEditing = draftValue !== null;
-
- useEffect(() => {
- if (!isEditing) {
- return;
- }
-
- valueInputReference.current?.focus();
- valueInputReference.current?.select();
- }, [isEditing]);
-
- const commitDraftValue = () => {
- if (draftValue === null) {
- return;
- }
-
- const nextValue = Number.parseFloat(draftValue);
- setDraftValue(null);
-
- if (!Number.isFinite(nextValue)) {
- return;
- }
-
- const normalizedValue = clampAndSnapValue(nextValue, min, max, step);
-
- onChange({
- target: { value: String(normalizedValue) },
- } as React.ChangeEvent);
- };
-
- return (
-
- {children}
-
- {isEditing ? (
- setDraftValue(event.target.value)}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- commitDraftValue();
- return;
- }
-
- if (event.key === 'Escape') {
- event.preventDefault();
- setDraftValue(null);
- }
- }}
- ref={valueInputReference}
- step={step}
- type="number"
- value={draftValue}
- />
- ) : (
- {
- event.preventDefault();
- event.stopPropagation();
- setDraftValue(formatEditableValue(value, step));
- }}
- onMouseDown={(event) => {
- event.preventDefault();
- event.stopPropagation();
- }}
- type="button"
- >
- {valueLabel}
-
- )}
-
- );
-}
-
-type SelectControlProps = {
- children: ReactNode;
- onChange: ChangeEventHandler;
- value: string;
- options: Array<{ label: string; value: string }>;
-};
-
-export function SelectControl({
- children,
- onChange,
- options,
- value,
-}: SelectControlProps) {
- return (
-
- {children}
-
- {options.map((option) => (
-
- {option.label}
-
- ))}
-
-
- );
-}
-
-type SegmentedControlProps = {
- children: ReactNode;
- onChange: (value: string) => void;
- value: string;
- options: Array<{ label: string; value: string }>;
-};
-
-export function SegmentedControl({
- children,
- onChange,
- options,
- value,
-}: SegmentedControlProps) {
- return (
-
- {children}
-
- {options.map((option) => {
- const isActive = option.value === value;
-
- return (
- {
- if (!isActive) {
- onChange(option.value);
- }
- }}
- role="radio"
- type="button"
- >
- {option.label}
-
- );
- })}
-
-
- );
-}
-
-type ToggleControlProps = {
- checked: boolean;
- label: ReactNode;
- onChange: ChangeEventHandler;
-};
-
-export function ToggleControl({
- checked,
- label,
- onChange,
-}: ToggleControlProps) {
- return (
-
- {label}
-
-
-
-
-
- );
-}
-
-type LabelWithTooltipProps = {
- description: string;
- label: ReactNode;
-};
-
-export function LabelWithTooltip({
- description,
- label,
-}: LabelWithTooltipProps) {
- const buttonReference = useRef(null);
- const tooltipId = useId();
- const [isMounted, setIsMounted] = useState(false);
- const [isOpen, setIsOpen] = useState(false);
- const [tooltipPosition, setTooltipPosition] = useState({
- left: 0,
- top: 0,
- });
-
- useEffect(() => {
- setIsMounted(true);
- }, []);
-
- useEffect(() => {
- if (!isOpen) {
- return;
- }
-
- const updateTooltipPosition = () => {
- const button = buttonReference.current;
-
- if (!button) {
- return;
- }
-
- const rect = button.getBoundingClientRect();
- setTooltipPosition({
- left: rect.left + rect.width / 2,
- top: rect.bottom + 12,
- });
- };
-
- updateTooltipPosition();
-
- window.addEventListener('resize', updateTooltipPosition);
- window.addEventListener('scroll', updateTooltipPosition, true);
-
- return () => {
- window.removeEventListener('resize', updateTooltipPosition);
- window.removeEventListener('scroll', updateTooltipPosition, true);
- };
- }, [isOpen]);
-
- return (
-
- {label}
-
- setIsOpen(false)}
- onFocus={() => setIsOpen(true)}
- onMouseEnter={() => setIsOpen(true)}
- onMouseLeave={() => setIsOpen(false)}
- ref={buttonReference}
- type="button"
- >
-
-
-
-
-
-
- {isMounted && isOpen
- ? createPortal(
-
- {description}
- ,
- document.body,
- )
- : null}
-
-
- );
-}
-
-type SectionToggleHeaderProps = {
- checked: boolean;
- children: ReactNode;
- onChange: ChangeEventHandler;
- preserveCase?: boolean;
-};
-
-export function SectionToggleHeader({
- checked,
- children,
- onChange,
- preserveCase,
-}: SectionToggleHeaderProps) {
- return (
-
- {children}
-
-
-
-
-
- );
-}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/effect-components/TooltipPositionEffect.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/effect-components/TooltipPositionEffect.tsx
new file mode 100644
index 0000000000..0fcc05263d
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/controls/effect-components/TooltipPositionEffect.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import { useEffect, type RefObject } from 'react';
+
+type TooltipPositionEffectProps = {
+ buttonRef: RefObject;
+ isOpen: boolean;
+ setTooltipPosition: (position: { left: number; top: number }) => void;
+};
+
+export function TooltipPositionEffect({
+ buttonRef,
+ isOpen,
+ setTooltipPosition,
+}: TooltipPositionEffectProps) {
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ const updateTooltipPosition = () => {
+ const button = buttonRef.current;
+
+ if (!button) {
+ return;
+ }
+
+ const rect = button.getBoundingClientRect();
+ setTooltipPosition({
+ left: rect.left + rect.width / 2,
+ top: rect.bottom + 12,
+ });
+ };
+
+ updateTooltipPosition();
+
+ window.addEventListener('resize', updateTooltipPosition);
+ window.addEventListener('scroll', updateTooltipPosition, true);
+
+ return () => {
+ window.removeEventListener('resize', updateTooltipPosition);
+ window.removeEventListener('scroll', updateTooltipPosition, true);
+ };
+ }, [isOpen, buttonRef, setTooltipPosition]);
+
+ return null;
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/effect-components/CopyHalftoneShortcutEffect.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/effect-components/CopyHalftoneShortcutEffect.tsx
new file mode 100644
index 0000000000..7aa8da8b42
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/effect-components/CopyHalftoneShortcutEffect.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import { useEffect } from 'react';
+
+function isEditablePasteTarget(target: EventTarget | null) {
+ if (!(target instanceof HTMLElement)) {
+ return false;
+ }
+
+ return (
+ target.isContentEditable ||
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target.closest('[contenteditable=""], [contenteditable="true"]') !== null
+ );
+}
+
+function hasTextSelection() {
+ const selection = window.getSelection();
+
+ return selection !== null && selection.toString().trim().length > 0;
+}
+
+type CopyHalftoneShortcutEffectProps = {
+ canvasLayerRef: React.RefObject;
+ onCopy: (width: number, height: number) => void;
+};
+
+export function CopyHalftoneShortcutEffect({
+ canvasLayerRef,
+ onCopy,
+}: CopyHalftoneShortcutEffectProps) {
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ const isCopyShortcut =
+ (event.metaKey || event.ctrlKey) &&
+ !event.shiftKey &&
+ !event.altKey &&
+ event.key.toLowerCase() === 'c';
+
+ if (!isCopyShortcut) {
+ return;
+ }
+
+ if (isEditablePasteTarget(event.target) || hasTextSelection()) {
+ return;
+ }
+
+ const canvasLayer = canvasLayerRef.current;
+ const width = Math.max(canvasLayer?.clientWidth ?? 0, 1);
+ const height = Math.max(canvasLayer?.clientHeight ?? 0, 1);
+
+ event.preventDefault();
+ onCopy(width, height);
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [canvasLayerRef, onCopy]);
+
+ return null;
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/effect-components/PasteImageEffect.tsx b/packages/twenty-website-new/src/app/[locale]/halftone/_components/effect-components/PasteImageEffect.tsx
new file mode 100644
index 0000000000..b8da6edb85
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/effect-components/PasteImageEffect.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+import { useEffect } from 'react';
+
+const CLIPBOARD_IMAGE_EXTENSION_BY_MIME: Record = {
+ 'image/bmp': '.bmp',
+ 'image/gif': '.gif',
+ 'image/jpeg': '.jpg',
+ 'image/png': '.png',
+ 'image/svg+xml': '.svg',
+ 'image/webp': '.webp',
+};
+
+function isEditablePasteTarget(target: EventTarget | null) {
+ if (!(target instanceof HTMLElement)) {
+ return false;
+ }
+
+ return (
+ target.isContentEditable ||
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target.closest('[contenteditable=""], [contenteditable="true"]') !== null
+ );
+}
+
+function createPastedImageFile(file: File) {
+ const hasExtension = /\.[^.]+$/i.test(file.name);
+
+ if (hasExtension) {
+ return file;
+ }
+
+ const extension =
+ CLIPBOARD_IMAGE_EXTENSION_BY_MIME[file.type] ??
+ CLIPBOARD_IMAGE_EXTENSION_BY_MIME['image/png'];
+
+ return new File([file], `pasted-image-${Date.now()}${extension}`, {
+ type: file.type || 'image/png',
+ lastModified: Date.now(),
+ });
+}
+
+type PasteImageEffectProps = {
+ onPasteImage: (file: File) => void;
+ onPasteError: () => void;
+};
+
+export function PasteImageEffect({
+ onPasteImage,
+ onPasteError,
+}: PasteImageEffectProps) {
+ useEffect(() => {
+ const handlePaste = (event: ClipboardEvent) => {
+ if (isEditablePasteTarget(event.target)) {
+ return;
+ }
+
+ const imageItem = Array.from(event.clipboardData?.items ?? []).find(
+ (item) => item.type.startsWith('image/'),
+ );
+
+ if (!imageItem) {
+ return;
+ }
+
+ const pastedFile = imageItem.getAsFile();
+
+ if (!pastedFile) {
+ onPasteError();
+ return;
+ }
+
+ event.preventDefault();
+ onPasteImage(createPastedImageFile(pastedFile));
+ };
+
+ window.addEventListener('paste', handlePaste);
+
+ return () => {
+ window.removeEventListener('paste', handlePaste);
+ };
+ }, [onPasteImage, onPasteError]);
+
+ return null;
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_components/halftone-studio.styles.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_components/halftone-studio.styles.ts
new file mode 100644
index 0000000000..cb82c6e90f
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_components/halftone-studio.styles.ts
@@ -0,0 +1,124 @@
+import { LocalizedLink } from '@/lib/i18n';
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+
+const DESKTOP_CONTROLS_PANEL_WIDTH = 320;
+const DESKTOP_CONTROLS_PANEL_OFFSET = 20;
+const DESKTOP_CONTROLS_PANEL_FOOTPRINT =
+ DESKTOP_CONTROLS_PANEL_WIDTH + DESKTOP_CONTROLS_PANEL_OFFSET;
+
+export const StudioShell = styled.div<{ $background: string }>`
+ background: ${(props) => props.$background};
+ /* Full-screen tool: track the visible viewport so the bottom controls bar
+ * is reachable on mobile Safari (where 100vh extends behind the URL bar). */
+ height: 100vh;
+ height: 100dvh;
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+`;
+
+export const ContentRegion = styled.div<{ $panelOpen: boolean }>`
+ height: 100%;
+ margin-right: auto;
+ position: relative;
+ transition: width 0.18s ease;
+ width: ${(props) =>
+ props.$panelOpen
+ ? `calc(100% - ${DESKTOP_CONTROLS_PANEL_FOOTPRINT}px)`
+ : '100%'};
+
+ @media (max-width: ${theme.breakpoints.md - 1}px) {
+ width: 100%;
+ }
+`;
+
+export const CanvasLayer = styled.div`
+ height: 100%;
+ width: 100%;
+`;
+
+export const LogoLink = styled(LocalizedLink)`
+ display: grid;
+ left: 24px;
+ position: absolute;
+ top: 24px;
+ z-index: 2;
+`;
+
+export const Hint = styled.div<{ $tone: 'dark' | 'light'; $visible: boolean }>`
+ color: ${(props) =>
+ props.$tone === 'dark'
+ ? 'rgba(255, 255, 255, 0.34)'
+ : 'rgba(0, 0, 0, 0.3)'};
+ font-family: ${theme.font.family.mono};
+ font-size: 11px;
+ left: 50%;
+ opacity: ${(props) => (props.$visible ? 1 : 0)};
+ pointer-events: none;
+ position: absolute;
+ top: 20px;
+ transform: translateX(-50%);
+ transition: opacity 0.3s ease;
+ z-index: 1;
+`;
+
+export const Status = styled.div<{
+ $error: boolean;
+ $tone: 'dark' | 'light';
+ $visible: boolean;
+}>`
+ color: ${(props) => {
+ if (props.$error) {
+ return 'rgba(214, 66, 66, 0.92)';
+ }
+
+ return props.$tone === 'dark'
+ ? 'rgba(255, 255, 255, 0.62)'
+ : 'rgba(0, 0, 0, 0.55)';
+ }};
+ font-family: ${theme.font.family.mono};
+ font-size: 11px;
+ left: 50%;
+ opacity: ${(props) => (props.$visible ? 1 : 0)};
+ pointer-events: none;
+ position: absolute;
+ top: 44px;
+ transform: translateX(-50%);
+ transition: opacity 0.2s ease;
+ z-index: 1;
+`;
+
+export const ControlsPositioner = styled.div`
+ align-items: flex-start;
+ bottom: 20px;
+ display: flex;
+ height: calc(100vh - 40px);
+ height: calc(100dvh - 40px);
+ justify-content: flex-end;
+ pointer-events: none;
+ position: fixed;
+ right: 20px;
+ top: 20px;
+ z-index: 3;
+
+ @media (max-width: ${theme.breakpoints.md - 1}px) {
+ bottom: 16px;
+ height: 48vh;
+ left: 16px;
+ right: 16px;
+ top: auto;
+ }
+`;
+
+export const ControlsPanelFrame = styled.div<{ $visible: boolean }>`
+ height: ${(props) => (props.$visible ? '100%' : 'auto')};
+ pointer-events: auto;
+ @media (max-width: ${theme.breakpoints.md - 1}px) {
+ width: ${(props) => (props.$visible ? '100%' : 'auto')};
+ }
+`;
+
+export const HiddenFileInput = styled.input`
+ display: none;
+`;
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-default-halftone-image.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-default-halftone-image.ts
new file mode 100644
index 0000000000..ad6f426630
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-default-halftone-image.ts
@@ -0,0 +1,23 @@
+'use client';
+
+import { useEffect } from 'react';
+
+type UseDefaultHalftoneImageOptions = {
+ loadDefaultImageFile: () => Promise;
+ setImageFile: (updater: (currentFile: File | null) => File | null) => void;
+};
+
+export function useDefaultHalftoneImage({
+ loadDefaultImageFile,
+ setImageFile,
+}: UseDefaultHalftoneImageOptions) {
+ useEffect(() => {
+ void loadDefaultImageFile()
+ .then((file) => {
+ setImageFile((currentFile) => currentFile ?? file);
+ })
+ .catch((error) => {
+ console.error(error);
+ });
+ }, [loadDefaultImageFile, setImageFile]);
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-geometry-loader.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-geometry-loader.ts
new file mode 100644
index 0000000000..436e434883
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-geometry-loader.ts
@@ -0,0 +1,97 @@
+import {
+ createFallbackGeometry,
+ disposeGeometryCache,
+ getGeometryForSpec,
+} from '@/lib/halftone/utils/geometry-registry';
+import type { HalftoneGeometrySpec } from '@/lib/halftone/utils/state';
+import { useEffect, useRef, useState } from 'react';
+import type * as THREE from 'three';
+
+type GeometryCacheEntry = THREE.BufferGeometry | Promise;
+
+type GeometryLoaderCallbacks = {
+ onLoadStart: (label: string) => void;
+ onLoadSuccess: () => void;
+ onLoadError: (message: string) => void;
+ onFallbackShape: (key: string) => void;
+};
+
+export function useGeometryLoader(
+ selectedShape: HalftoneGeometrySpec | undefined,
+ importedFiles: Record,
+ callbacks: GeometryLoaderCallbacks,
+) {
+ const [activeGeometry, setActiveGeometry] = useState(
+ () => createFallbackGeometry(),
+ );
+ const geometryCacheReference = useRef>(
+ new Map([['torusKnot', activeGeometry]]),
+ );
+ const lastSuccessfulShapeReference = useRef(
+ selectedShape?.key ?? 'torusKnot',
+ );
+
+ useEffect(() => {
+ const geometryCache = geometryCacheReference.current;
+
+ return () => {
+ disposeGeometryCache(geometryCache);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!selectedShape) {
+ return;
+ }
+
+ let cancelled = false;
+ const geometryCache = geometryCacheReference.current;
+
+ // Invalidate cached geometry when an imported file changes so we don't
+ // serve stale geometry from a previous file under the same key.
+ if (selectedShape.kind === 'imported') {
+ geometryCache.delete(selectedShape.key);
+ callbacks.onLoadStart(selectedShape.label);
+ } else {
+ callbacks.onLoadSuccess();
+ }
+
+ void getGeometryForSpec(
+ selectedShape,
+ importedFiles[selectedShape.key],
+ geometryCache,
+ )
+ .then((geometry) => {
+ if (cancelled) {
+ return;
+ }
+
+ lastSuccessfulShapeReference.current = selectedShape.key;
+ setActiveGeometry(geometry);
+ callbacks.onLoadSuccess();
+ })
+ .catch((error) => {
+ if (cancelled) {
+ return;
+ }
+
+ console.error(error);
+
+ callbacks.onLoadError(
+ error instanceof Error
+ ? error.message
+ : `${selectedShape.label} failed to load.`,
+ );
+
+ if (lastSuccessfulShapeReference.current !== selectedShape.key) {
+ callbacks.onFallbackShape(lastSuccessfulShapeReference.current);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedShape, importedFiles, callbacks]);
+
+ return activeGeometry;
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-image-element.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-image-element.ts
new file mode 100644
index 0000000000..e39efaa1b5
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/halftone/_hooks/use-image-element.ts
@@ -0,0 +1,41 @@
+import { useEffect, useState } from 'react';
+
+export function useImageElement(imageFile: File | null) {
+ const [imageElement, setImageElement] = useState(
+ null,
+ );
+
+ useEffect(() => {
+ if (!imageFile) {
+ setImageElement(null);
+ return;
+ }
+
+ let cancelled = false;
+ const url = URL.createObjectURL(imageFile);
+ const img = new Image();
+
+ img.onload = () => {
+ if (!cancelled) {
+ setImageElement(img);
+ }
+ };
+
+ img.onerror = () => {
+ if (!cancelled) {
+ setImageElement(null);
+ }
+ };
+
+ img.src = url;
+
+ return () => {
+ cancelled = true;
+ img.onload = null;
+ img.onerror = null;
+ URL.revokeObjectURL(url);
+ };
+ }, [imageFile]);
+
+ return imageElement;
+}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exporters.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exporters.ts
deleted file mode 100644
index 1dd512a252..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exporters.ts
+++ /dev/null
@@ -1,3467 +0,0 @@
-import { normalizeExportComponentName } from '@/app/[locale]/halftone/_lib/exportNames';
-import { GLASS_ENVIRONMENT_DATA_URL } from '@/app/[locale]/halftone/_lib/glassEnvironmentData';
-import {
- HALFTONE_FOOTPRINT_RUNTIME_SOURCE,
- REFERENCE_PREVIEW_DISTANCE,
- VIRTUAL_RENDER_HEIGHT,
-} from '@/lib/halftone/footprint';
-import {
- LEGACY_HALFTONE_SETTING_KEYS,
- isRoundedBandHalftoneSettings,
- type HalftoneExportPose,
- type HalftoneGeometrySpec,
- type HalftoneStudioSettings,
-} from '@/lib/halftone/state';
-import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path';
-
-export type ReactExportSettings = {
- includeNamedAndDefaultExport: boolean;
- includePublicAssetUrl: boolean;
- includeRegistryComment: boolean;
- includeTsNoCheck: boolean;
- includeUseClientDirective: boolean;
- includeStyledMount: boolean;
-};
-
-export type ReactExportOptions = {
- assetUrl?: string;
- background?: string;
- exportSettings?: Partial;
- imageFilename?: string;
- importedFile?: File;
- initialPose?: HalftoneExportPose;
- modelFilenameOverride?: string;
- previewDistance?: number;
-};
-
-export const DEFAULT_REACT_EXPORT_SETTINGS: ReactExportSettings = {
- includeNamedAndDefaultExport: true,
- includePublicAssetUrl: true,
- includeRegistryComment: true,
- includeTsNoCheck: true,
- includeUseClientDirective: true,
- includeStyledMount: true,
-};
-
-const passThroughVertexShader = `
- varying vec2 vUv;
-
- void main() {
- vUv = uv;
- gl_Position = vec4(position, 1.0);
- }
-`;
-
-const blurFragmentShader = `
- precision highp float;
-
- uniform sampler2D tInput;
- uniform vec2 dir;
- uniform vec2 res;
-
- varying vec2 vUv;
-
- void main() {
- vec4 sum = vec4(0.0);
- vec2 px = dir / res;
-
- float w[5];
- w[0] = 0.227027;
- w[1] = 0.1945946;
- w[2] = 0.1216216;
- w[3] = 0.054054;
- w[4] = 0.016216;
-
- sum += texture2D(tInput, vUv) * w[0];
-
- for (int i = 1; i < 5; i++) {
- float fi = float(i) * 3.0;
- sum += texture2D(tInput, vUv + px * fi) * w[i];
- sum += texture2D(tInput, vUv - px * fi) * w[i];
- }
-
- gl_FragColor = sum;
- }
-`;
-
-const imagePassthroughFragmentShader = `
- precision highp float;
-
- uniform sampler2D tImage;
- uniform vec2 imageSize;
- uniform vec2 viewportSize;
- uniform float zoom;
- uniform float contrast;
-
- varying vec2 vUv;
-
- void main() {
- float imageAspect = imageSize.x / imageSize.y;
- float viewAspect = viewportSize.x / viewportSize.y;
-
- vec2 uv = vUv;
-
- if (imageAspect > viewAspect) {
- float scale = viewAspect / imageAspect;
- uv.y = (uv.y - 0.5) / scale + 0.5;
- } else {
- float scale = imageAspect / viewAspect;
- uv.x = (uv.x - 0.5) / scale + 0.5;
- }
-
- uv = (uv - 0.5) / zoom + 0.5;
-
- 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 sampler2D tGlow;
- 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 time;
- uniform float waveAmount;
- uniform float waveSpeed;
- uniform float footprintScale;
- uniform vec2 interactionUv;
- uniform vec2 interactionVelocity;
- uniform vec2 dragOffset;
- uniform float hoverHalftoneActive;
- uniform float hoverHalftonePowerShift;
- uniform float hoverHalftoneRadius;
- uniform float hoverHalftoneWidthShift;
- uniform float hoverLightStrength;
- uniform float hoverLightRadius;
- uniform float hoverFlowStrength;
- uniform float hoverFlowRadius;
- uniform float dragFlowStrength;
- uniform float cropToBounds;
-
- 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);
- vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
- float velocityMagnitude = length(interactionVelocity);
- vec2 motionDir = velocityMagnitude > 0.001
- ? interactionVelocity / velocityMagnitude
- : vec2(0.0, 0.0);
- float motionBias = velocityMagnitude > 0.001
- ? dot(-radialDir, motionDir) * 0.5 + 0.5
- : 0.5;
-
- float hoverLightMask = 0.0;
- if (hoverLightStrength > 0.0) {
- float lightRadiusPx = hoverLightRadius * logicalResolution.y;
- hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
- }
-
- float hoverHalftoneMask = 0.0;
- if (hoverHalftoneActive > 0.0) {
- float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;
- hoverHalftoneMask =
- smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) *
- clamp(hoverHalftoneActive, 0.0, 1.0);
- }
-
- float hoverFlowMask = 0.0;
- if (hoverFlowStrength > 0.0) {
- float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;
- hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
- }
-
- vec2 hoverDisplacement =
- radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +
- motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15;
- vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;
- vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;
-
- float bandRow = floor(effectCoord.y / halftoneSize);
- float waveOffset =
- waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;
- effectCoord.x += waveOffset;
-
- 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 + hoverHalftonePowerShift * hoverHalftoneMask,
- -1.5,
- 1.5
- );
- float localWidth = clamp(
- s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,
- 0.05,
- 1.4
- );
- float lightLift =
- hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
- float toneValue =
- (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0);
- 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 + powerBias + 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;
- }
-
- vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);
- vec3 color = activeDashColor * alpha;
- gl_FragColor = vec4(color, alpha);
-
- #include
- #include
- }
-`;
-
-const HALFTONE_TRANSMISSION_SHADER_PREFIX = String.raw`
-uniform float chromaticAberration;
-uniform float anisotropicBlur;
-uniform float time;
-uniform float distortion;
-uniform float distortionScale;
-uniform float temporalDistortion;
-uniform sampler2D buffer;
-
-vec3 random3(vec3 c) {
- float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0)));
- vec3 r;
- r.z = fract(512.0 * j);
- j *= 0.125;
- r.x = fract(512.0 * j);
- j *= 0.125;
- r.y = fract(512.0 * j);
- return r - 0.5;
-}
-
-uint hash(uint x) {
- x += (x << 10u);
- x ^= (x >> 6u);
- x += (x << 3u);
- x ^= (x >> 11u);
- x += (x << 15u);
- return x;
-}
-
-uint hash(uvec2 v) { return hash(v.x ^ hash(v.y)); }
-uint hash(uvec3 v) { return hash(v.x ^ hash(v.y) ^ hash(v.z)); }
-uint hash(uvec4 v) {
- return hash(v.x ^ hash(v.y) ^ hash(v.z) ^ hash(v.w));
-}
-
-float floatConstruct(uint m) {
- const uint ieeeMantissa = 0x007FFFFFu;
- const uint ieeeOne = 0x3F800000u;
- m &= ieeeMantissa;
- m |= ieeeOne;
- float f = uintBitsToFloat(m);
- return f - 1.0;
-}
-
-float randomBase(float x) {
- return floatConstruct(hash(floatBitsToUint(x)));
-}
-float randomBase(vec2 v) {
- return floatConstruct(hash(floatBitsToUint(v)));
-}
-float randomBase(vec3 v) {
- return floatConstruct(hash(floatBitsToUint(v)));
-}
-float randomBase(vec4 v) {
- return floatConstruct(hash(floatBitsToUint(v)));
-}
-
-float rand(float seed) {
- return randomBase(vec3(gl_FragCoord.xy, seed));
-}
-
-const float F3 = 0.3333333;
-const float G3 = 0.1666667;
-
-float snoise(vec3 p) {
- vec3 s = floor(p + dot(p, vec3(F3)));
- vec3 x = p - s + dot(s, vec3(G3));
- vec3 e = step(vec3(0.0), x - x.yzx);
- vec3 i1 = e * (1.0 - e.zxy);
- vec3 i2 = 1.0 - e.zxy * (1.0 - e);
- vec3 x1 = x - i1 + G3;
- vec3 x2 = x - i2 + 2.0 * G3;
- vec3 x3 = x - 1.0 + 3.0 * G3;
- vec4 w;
- vec4 d;
- w.x = dot(x, x);
- w.y = dot(x1, x1);
- w.z = dot(x2, x2);
- w.w = dot(x3, x3);
- w = max(0.6 - w, 0.0);
- d.x = dot(random3(s), x);
- d.y = dot(random3(s + i1), x1);
- d.z = dot(random3(s + i2), x2);
- d.w = dot(random3(s + 1.0), x3);
- w *= w;
- w *= w;
- d *= w;
- return dot(d, vec4(52.0));
-}
-
-float snoiseFractal(vec3 m) {
- return 0.5333333 * snoise(m)
- + 0.2666667 * snoise(2.0 * m)
- + 0.1333333 * snoise(4.0 * m)
- + 0.0666667 * snoise(8.0 * m);
-}
-`;
-
-const HALFTONE_TRANSMISSION_PARS_FRAGMENT = String.raw`
-#ifdef USE_TRANSMISSION
- uniform float _transmission;
- uniform float thickness;
- uniform float attenuationDistance;
- uniform vec3 attenuationColor;
- uniform sampler2D refractionEnvMap;
- uniform float useEnvMapRefraction;
- #ifdef USE_TRANSMISSIONMAP
- uniform sampler2D transmissionMap;
- #endif
- #ifdef USE_THICKNESSMAP
- uniform sampler2D thicknessMap;
- #endif
- uniform vec2 transmissionSamplerSize;
- uniform sampler2D transmissionSamplerMap;
- uniform mat4 modelMatrix;
- uniform mat4 projectionMatrix;
- varying vec3 vWorldPosition;
-
- vec3 getVolumeTransmissionRay(
- const in vec3 n,
- const in vec3 v,
- const in float thicknessValue,
- const in float ior,
- const in mat4 modelMatrix
- ) {
- vec3 refractionVector = refract(-v, normalize(n), 1.0 / ior);
- vec3 modelScale;
- modelScale.x = length(vec3(modelMatrix[0].xyz));
- modelScale.y = length(vec3(modelMatrix[1].xyz));
- modelScale.z = length(vec3(modelMatrix[2].xyz));
- return normalize(refractionVector) * thicknessValue * modelScale;
- }
-
- float applyIorToRoughness(
- const in float roughnessValue,
- const in float ior
- ) {
- return roughnessValue * clamp(ior * 2.0 - 2.0, 0.0, 1.0);
- }
-
- vec2 directionToEquirectUv(const in vec3 direction) {
- vec3 dir = normalize(direction);
- vec2 uv = vec2(
- atan(dir.z, dir.x) * 0.15915494309189535 + 0.5,
- asin(clamp(dir.y, -1.0, 1.0)) * 0.3183098861837907 + 0.5
- );
-
- return vec2(fract(uv.x), 1.0 - clamp(uv.y, 0.0, 1.0));
- }
-
- vec4 getTransmissionSample(
- const in vec2 fragCoord,
- const in vec3 transmissionDirection,
- const in float roughnessValue,
- const in float ior
- ) {
- if (useEnvMapRefraction > 0.5) {
- return texture2D(
- refractionEnvMap,
- directionToEquirectUv(transmissionDirection)
- );
- }
-
- float framebufferLod =
- log2(transmissionSamplerSize.x) *
- applyIorToRoughness(roughnessValue, ior);
- return texture2D(buffer, fragCoord.xy);
- }
-
- vec3 applyVolumeAttenuation(
- const in vec3 radiance,
- const in float transmissionDistance,
- const in vec3 attenuationColorValue,
- const in float attenuationDistanceValue
- ) {
- if (isinf(attenuationDistanceValue)) {
- return radiance;
- }
-
- vec3 attenuationCoefficient =
- -log(attenuationColorValue) / attenuationDistanceValue;
- vec3 transmittance =
- exp(-attenuationCoefficient * transmissionDistance);
-
- return transmittance * radiance;
- }
-
- vec4 getIBLVolumeRefraction(
- const in vec3 n,
- const in vec3 v,
- const in float roughnessValue,
- const in vec3 diffuseColor,
- const in vec3 specularColor,
- const in float specularF90,
- const in vec3 position,
- const in mat4 modelMatrix,
- const in mat4 viewMatrix,
- const in mat4 projMatrix,
- const in float ior,
- const in float thicknessValue,
- const in vec3 attenuationColorValue,
- const in float attenuationDistanceValue
- ) {
- vec3 transmissionRay = getVolumeTransmissionRay(
- n,
- v,
- thicknessValue,
- ior,
- modelMatrix
- );
- vec3 refractedRayExit = position + transmissionRay;
- vec4 ndcPos =
- projMatrix * viewMatrix * vec4(refractedRayExit, 1.0);
- vec2 refractionCoords = ndcPos.xy / ndcPos.w;
- refractionCoords += 1.0;
- refractionCoords /= 2.0;
- vec3 transmissionDirection = normalize(transmissionRay);
- vec4 transmittedLight = getTransmissionSample(
- refractionCoords,
- transmissionDirection,
- roughnessValue,
- ior
- );
- vec3 attenuatedColor = applyVolumeAttenuation(
- transmittedLight.rgb,
- length(transmissionRay),
- attenuationColorValue,
- attenuationDistanceValue
- );
- vec3 F = EnvironmentBRDF(
- n,
- v,
- specularColor,
- specularF90,
- roughnessValue
- );
- return vec4(
- (1.0 - F) * attenuatedColor * diffuseColor,
- transmittedLight.a
- );
- }
-#endif
-`;
-
-const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = String.raw`
-material.transmission = _transmission;
-material.transmissionAlpha = 1.0;
-material.thickness = thickness;
-material.attenuationDistance = attenuationDistance;
-material.attenuationColor = attenuationColor;
-#ifdef USE_TRANSMISSIONMAP
- material.transmission *= texture2D(transmissionMap, vUv).r;
-#endif
-#ifdef USE_THICKNESSMAP
- material.thickness *= texture2D(thicknessMap, vUv).g;
-#endif
-
-vec3 pos = vWorldPosition;
-float runningSeed = 0.0;
-vec3 v = normalize(cameraPosition - pos);
-vec3 n = inverseTransformDirection(normal, viewMatrix);
-vec3 transmission = vec3(0.0);
-float transmissionR;
-float transmissionG;
-float transmissionB;
-float randomCoords = rand(runningSeed++);
-float thicknessSmear =
- thickness * max(pow(roughnessFactor, 0.33), anisotropicBlur);
-vec3 distortionNormal = vec3(0.0);
-vec3 temporalOffset = vec3(time, -time, -time) * temporalDistortion;
-
-if (distortion > 0.0) {
- distortionNormal = distortion * vec3(
- snoiseFractal(vec3(pos * distortionScale + temporalOffset)),
- snoiseFractal(vec3(pos.zxy * distortionScale - temporalOffset)),
- snoiseFractal(vec3(pos.yxz * distortionScale + temporalOffset))
- );
-}
-
-for (float i = 0.0; i < __SAMPLES__.0; i++) {
- vec3 sampleNorm = normalize(
- n +
- roughnessFactor * roughnessFactor * 2.0 *
- normalize(
- vec3(
- rand(runningSeed++) - 0.5,
- rand(runningSeed++) - 0.5,
- rand(runningSeed++) - 0.5
- )
- ) *
- pow(rand(runningSeed++), 0.33) +
- distortionNormal
- );
-
- transmissionR = getIBLVolumeRefraction(
- sampleNorm,
- v,
- material.roughness,
- material.diffuseColor,
- material.specularColor,
- material.specularF90,
- pos,
- modelMatrix,
- viewMatrix,
- projectionMatrix,
- material.ior,
- material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),
- material.attenuationColor,
- material.attenuationDistance
- ).r;
-
- transmissionG = getIBLVolumeRefraction(
- sampleNorm,
- v,
- material.roughness,
- material.diffuseColor,
- material.specularColor,
- material.specularF90,
- pos,
- modelMatrix,
- viewMatrix,
- projectionMatrix,
- material.ior * (1.0 + chromaticAberration * (i + randomCoords) / float(__SAMPLES__)),
- material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),
- material.attenuationColor,
- material.attenuationDistance
- ).g;
-
- transmissionB = getIBLVolumeRefraction(
- sampleNorm,
- v,
- material.roughness,
- material.diffuseColor,
- material.specularColor,
- material.specularF90,
- pos,
- modelMatrix,
- viewMatrix,
- projectionMatrix,
- material.ior * (1.0 + 2.0 * chromaticAberration * (i + randomCoords) / float(__SAMPLES__)),
- material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),
- material.attenuationColor,
- material.attenuationDistance
- ).b;
-
- transmission.r += transmissionR;
- transmission.g += transmissionG;
- transmission.b += transmissionB;
-}
-
-transmission /= __SAMPLES__.0;
-totalDiffuse = mix(totalDiffuse, transmission.rgb, material.transmission);
-`;
-
-const GEOMETRY_RUNTIME_SOURCE = String.raw`
-function makePolarShape(radiusFunction, segments = 320) {
- const shape = new THREE.Shape();
-
- for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
- const angle = (segmentIndex / segments) * Math.PI * 2;
- const radius = radiusFunction(angle);
- const x = Math.cos(angle) * radius;
- const y = Math.sin(angle) * radius;
-
- if (segmentIndex === 0) {
- shape.moveTo(x, y);
- } else {
- shape.lineTo(x, y);
- }
- }
-
- return shape;
-}
-
-function makeReliefGeometry(shape, options = {}) {
- const {
- bevelSegments = 8,
- bevelSize = 0.08,
- bevelThickness = 0.1,
- depth = 0.58,
- waveDepth = 0.016,
- waves = 8,
- } = options;
-
- const geometry = new THREE.ExtrudeGeometry(shape, {
- depth,
- steps: 2,
- bevelEnabled: true,
- bevelThickness,
- bevelSize,
- bevelSegments,
- curveSegments: 96,
- });
-
- geometry.center();
-
- const position = geometry.attributes.position;
- let maxRadius = 0;
-
- for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) {
- maxRadius = Math.max(
- maxRadius,
- Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)),
- );
- }
-
- const fullDepth = depth + bevelThickness * 2;
-
- for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) {
- const x = position.getX(vertexIndex);
- const y = position.getY(vertexIndex);
- const z = position.getZ(vertexIndex);
- const radius = Math.hypot(x, y) / maxRadius;
- const angle = Math.atan2(y, x);
- const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5));
- const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2));
- const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2));
- const wave =
- Math.cos(angle * waves) *
- Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) *
- waveDepth;
- const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave);
-
- position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief);
- }
-
- position.needsUpdate = true;
- geometry.computeVertexNormals();
- geometry.computeBoundingBox();
- geometry.computeBoundingSphere();
-
- return geometry;
-}
-
-function mergeGeometries(geometries) {
- if (geometries.length === 1) {
- return geometries[0];
- }
-
- let totalVertices = 0;
- let totalIndices = 0;
- let hasUv = false;
-
- const geometryInfos = geometries.map((geometry) => {
- const position = geometry.attributes.position;
- const normal = geometry.attributes.normal;
- const uv = geometry.attributes.uv ?? null;
- const index = geometry.index;
- const indexCount = index ? index.count : position.count;
-
- totalVertices += position.count;
- totalIndices += indexCount;
- hasUv = hasUv || uv !== null;
-
- return {
- index,
- indexCount,
- normal,
- position,
- uv,
- vertexCount: position.count,
- };
- });
-
- const positions = new Float32Array(totalVertices * 3);
- const normals = new Float32Array(totalVertices * 3);
- const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
- const indices = new Uint32Array(totalIndices);
-
- let vertexOffset = 0;
- let indexOffset = 0;
-
- for (const geometryInfo of geometryInfos) {
- for (let vertexIndex = 0; vertexIndex < geometryInfo.vertexCount; vertexIndex += 1) {
- const positionOffset = (vertexOffset + vertexIndex) * 3;
- positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
- positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
- positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
- normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
- normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
- normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
-
- if (uvs !== null) {
- const uvOffset = (vertexOffset + vertexIndex) * 2;
- uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
- uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
- }
- }
-
- if (geometryInfo.index) {
- for (let localIndex = 0; localIndex < geometryInfo.indexCount; localIndex += 1) {
- indices[indexOffset + localIndex] =
- geometryInfo.index.getX(localIndex) + vertexOffset;
- }
- } else {
- for (let localIndex = 0; localIndex < geometryInfo.indexCount; localIndex += 1) {
- indices[indexOffset + localIndex] = localIndex + vertexOffset;
- }
- }
-
- vertexOffset += geometryInfo.vertexCount;
- indexOffset += geometryInfo.indexCount;
- }
-
- const merged = new THREE.BufferGeometry();
- merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
- merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
-
- if (uvs !== null) {
- merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
- }
-
- merged.setIndex(new THREE.BufferAttribute(indices, 1));
-
- return merged;
-}
-
-function makeArrowTarget() {
- const targetParts = [];
- const arrowParts = [];
- const baseRadius = 1.35;
- const baseDepth = 0.32;
- const bevel = 0.12;
- const points = [];
- const segments = 16;
-
- points.push(new THREE.Vector2(0, -baseDepth / 2));
- points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2));
-
- for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
- const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2);
- points.push(
- new THREE.Vector2(
- baseRadius - bevel + Math.cos(angle) * bevel,
- -baseDepth / 2 + bevel + Math.sin(angle) * bevel,
- ),
- );
- }
-
- points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel));
-
- for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
- const angle = (segmentIndex / segments) * (Math.PI / 2);
- points.push(
- new THREE.Vector2(
- baseRadius - bevel + Math.cos(angle) * bevel,
- baseDepth / 2 - bevel + Math.sin(angle) * bevel,
- ),
- );
- }
-
- points.push(new THREE.Vector2(0, baseDepth / 2));
-
- const disc = new THREE.LatheGeometry(points, 64);
- disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
- targetParts.push(disc);
-
- for (const radius of [0.45, 0.85, 1.22]) {
- const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64);
- ring.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04),
- );
- targetParts.push(ring);
- }
-
- const bump = new THREE.SphereGeometry(0.32, 32, 24, 0, Math.PI * 2, 0, Math.PI / 2);
- bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2));
- bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2));
- targetParts.push(bump);
-
- const shaftLength = 1.5;
- const shaftRadius = 0.05;
- const shaft = new THREE.CylinderGeometry(shaftRadius, shaftRadius, shaftLength, 10, 1);
- shaft.applyMatrix4(new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0));
- arrowParts.push(shaft);
-
- const head = new THREE.ConeGeometry(0.12, 0.35, 10);
- head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0));
- arrowParts.push(head);
-
- for (let finIndex = 0; finIndex < 3; finIndex += 1) {
- const finShape = new THREE.Shape();
- finShape.moveTo(0, 0);
- finShape.lineTo(0.22, 0.25);
- finShape.lineTo(0, 0.5);
- finShape.lineTo(0, 0);
-
- const finGeometry = new THREE.ExtrudeGeometry(finShape, {
- depth: 0.012,
- bevelEnabled: false,
- });
-
- finGeometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0.05, 0, -0.006));
- finGeometry.applyMatrix4(
- new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3),
- );
- finGeometry.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0),
- );
- arrowParts.push(finGeometry);
- }
-
- const nock = new THREE.SphereGeometry(0.065, 8, 8);
- nock.applyMatrix4(new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0));
- arrowParts.push(nock);
-
- const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15);
- const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5);
- const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12);
-
- for (const geometry of arrowParts) {
- geometry.applyMatrix4(aim);
- geometry.applyMatrix4(tilt);
- geometry.applyMatrix4(shift);
- }
-
- const merged = mergeGeometries([...targetParts, ...arrowParts]);
- merged.computeVertexNormals();
- merged.computeBoundingSphere();
-
- return merged;
-}
-
-function makeDollarCoin() {
- const parts = [];
- const baseRadius = 1.3;
- const baseDepth = 0.45;
- const bevel = 0.18;
- const points = [];
- const segments = 20;
-
- points.push(new THREE.Vector2(0, -baseDepth / 2));
- points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2));
-
- for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
- const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI;
- points.push(
- new THREE.Vector2(
- baseRadius - bevel + Math.cos(angle) * bevel,
- Math.sin(angle) * (baseDepth / 2),
- ),
- );
- }
-
- points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2));
- points.push(new THREE.Vector2(0, baseDepth / 2));
-
- const disc = new THREE.LatheGeometry(points, 64);
- disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
- parts.push(disc);
-
- const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64);
- frontRim.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01),
- );
- parts.push(frontRim);
-
- const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64);
- backRim.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)),
- );
- parts.push(backRim);
-
- const createDollarSign = () => {
- const geometries = [];
- const tubeRadius = 0.1;
- const curveRadius = 0.28;
- const verticalOffset = 0.22;
-
- const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12);
- geometries.push(bar);
-
- const topArc = new THREE.TorusGeometry(curveRadius, tubeRadius, 16, 32, Math.PI);
- topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
- topArc.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0),
- );
- geometries.push(topArc);
-
- const bottomArc = new THREE.TorusGeometry(curveRadius, tubeRadius, 16, 32, Math.PI);
- bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2));
- bottomArc.applyMatrix4(
- new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0),
- );
- geometries.push(bottomArc);
-
- const topSerif = new THREE.CylinderGeometry(tubeRadius, tubeRadius, 0.22, 12);
- topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
- topSerif.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0.16, verticalOffset + curveRadius, 0),
- );
- geometries.push(topSerif);
-
- const bottomSerif = new THREE.CylinderGeometry(tubeRadius, tubeRadius, 0.22, 12);
- bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
- bottomSerif.applyMatrix4(
- new THREE.Matrix4().makeTranslation(-0.16, -verticalOffset - curveRadius, 0),
- );
- geometries.push(bottomSerif);
-
- const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2);
- const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1);
- const diagonal = new THREE.CylinderGeometry(tubeRadius, tubeRadius, diagonalLength + 0.12, 12);
- diagonal.applyMatrix4(
- new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2),
- );
- geometries.push(diagonal);
-
- return geometries;
- };
-
- for (const geometry of createDollarSign()) {
- geometry.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01),
- );
- parts.push(geometry);
- }
-
- for (const geometry of createDollarSign()) {
- geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI));
- geometry.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)),
- );
- parts.push(geometry);
- }
-
- const merged = mergeGeometries(parts);
- merged.computeVertexNormals();
- merged.computeBoundingSphere();
-
- return merged;
-}
-
-function createBuiltinGeometry(shapeKey) {
- switch (shapeKey) {
- case 'torusKnot':
- return new THREE.TorusKnotGeometry(1, 0.35, 200, 32);
- case 'sphere':
- return new THREE.SphereGeometry(1.4, 64, 64);
- case 'torus':
- return new THREE.TorusGeometry(1, 0.45, 64, 100);
- case 'icosahedron':
- return new THREE.IcosahedronGeometry(1.4, 4);
- case 'box':
- return new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6);
- case 'cone':
- return new THREE.ConeGeometry(1.2, 2.4, 64, 10);
- case 'cylinder':
- return new THREE.CylinderGeometry(1, 1, 2.3, 64, 10);
- case 'octahedron':
- return new THREE.OctahedronGeometry(1.5, 2);
- case 'dodecahedron':
- return new THREE.DodecahedronGeometry(1.35, 1);
- case 'tetrahedron':
- return new THREE.TetrahedronGeometry(1.7, 1);
- case 'sunCoin':
- return makeReliefGeometry(
- makePolarShape(
- (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5),
- ),
- { depth: 0.62, waves: 12, waveDepth: 0.018 },
- );
- case 'lotusCoin':
- return makeReliefGeometry(
- makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)),
- { depth: 0.64, waves: 8, waveDepth: 0.014 },
- );
- case 'arrowTarget':
- return makeArrowTarget();
- case 'dollarCoin':
- return makeDollarCoin();
- default:
- return new THREE.TorusKnotGeometry(1, 0.35, 200, 32);
- }
-}
-`;
-
-const IMPORTED_RUNTIME_SHARED_SOURCE = String.raw`
-const EMPTY_TEXTURE_DATA_URL =
- 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
-
-function createLoadingManager() {
- const loadingManager = new THREE.LoadingManager();
- loadingManager.setURLModifier((url) =>
- /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
- );
- return loadingManager;
-}
-
-function normalizeImportedGeometry(geometry) {
- geometry.computeBoundingBox();
-
- let boundingBox = geometry.boundingBox;
- let center = new THREE.Vector3();
- let size = new THREE.Vector3();
-
- boundingBox?.getCenter(center);
- boundingBox?.getSize(size);
- geometry.translate(-center.x, -center.y, -center.z);
-
- const dimensions = [size.x, size.y, size.z];
- const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
-
- if (thinnestAxis === 0) {
- geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
- } else if (thinnestAxis === 1) {
- geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
- }
-
- geometry.computeBoundingBox();
- geometry.computeBoundingSphere();
-
- const radius = geometry.boundingSphere?.radius || 1;
- const scale = 1.6 / radius;
- geometry.scale(scale, scale, scale);
-
- geometry.computeBoundingBox();
- boundingBox = geometry.boundingBox;
- center = new THREE.Vector3();
- boundingBox?.getCenter(center);
- geometry.translate(-center.x, -center.y, -center.z);
-
- geometry.computeVertexNormals();
- geometry.computeBoundingBox();
- geometry.computeBoundingSphere();
-
- return geometry;
-}
-
-function extractMergedGeometry(root, emptyMessage) {
- root.updateMatrixWorld(true);
- const geometries = [];
-
- root.traverse((object) => {
- if (!(object instanceof THREE.Mesh) || !object.geometry) {
- return;
- }
-
- const geometry = object.geometry.clone();
-
- if (!geometry.attributes.normal) {
- geometry.computeVertexNormals();
- }
-
- geometry.applyMatrix4(object.matrixWorld);
- geometries.push(geometry);
- });
-
- if (geometries.length === 0) {
- throw new Error(emptyMessage);
- }
-
- return normalizeImportedGeometry(mergeGeometries(geometries));
-}
-`;
-
-const IMPORTED_FBX_RUNTIME_SOURCE = String.raw`
-
-function parseFbxGeometry(buffer, label) {
- const originalWarn = console.warn;
-
- console.warn = (...args) => {
- if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) {
- return;
- }
-
- originalWarn(...args);
- };
-
- try {
- const root = new FBXLoader(createLoadingManager()).parse(buffer, '');
- return extractMergedGeometry(root, label + ' did not contain any mesh geometry.');
- } finally {
- console.warn = originalWarn;
- }
-}
-`;
-
-const IMPORTED_GLB_RUNTIME_SOURCE = String.raw`
-const DRACO_DECODER_PATH = ${JSON.stringify(DRACO_DECODER_PATH)};
-
-function parseGlbGeometry(buffer, label) {
- return new Promise((resolve, reject) => {
- const loadingManager = createLoadingManager();
- const dracoLoader = new DRACOLoader(loadingManager);
- dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
-
- const loader = new GLTFLoader(loadingManager);
- loader.setDRACOLoader(dracoLoader);
-
- const cleanup = () => {
- dracoLoader.dispose();
- };
-
- loader.parse(
- buffer,
- '',
- (gltf) => {
- try {
- resolve(
- extractMergedGeometry(
- gltf.scene,
- label + ' did not contain any mesh geometry.',
- ),
- );
- } catch (error) {
- reject(error);
- } finally {
- cleanup();
- }
- },
- (error) => {
- cleanup();
- reject(error);
- },
- );
- });
-}
-`;
-
-function createImportedRuntimeSource(loader: HalftoneGeometrySpec['loader']) {
- const loaderSource =
- loader === 'fbx'
- ? IMPORTED_FBX_RUNTIME_SOURCE
- : IMPORTED_GLB_RUNTIME_SOURCE;
-
- return `${IMPORTED_RUNTIME_SHARED_SOURCE}
-${loaderSource}
-
-async function loadImportedGeometryFromUrl(modelUrl, label) {
- const response = await fetch(modelUrl);
-
- if (!response.ok) {
- throw new Error('Unable to load ' + label + ' from ' + modelUrl + '.');
- }
-
- const buffer = await response.arrayBuffer();
-
- ${
- loader === 'fbx'
- ? 'return parseFbxGeometry(buffer, label);'
- : 'return parseGlbGeometry(buffer, label);'
- }
-}
-`;
-}
-
-type ExportedShapeDescriptor = {
- filename: string | null;
- key: string;
- kind: HalftoneGeometrySpec['kind'];
- label: string;
- loader: HalftoneGeometrySpec['loader'] | null;
-};
-
-export type ParsedExportedPreset = {
- componentName: string | null;
- imageAssetReference: string | null;
- initialPose: HalftoneExportPose;
- modelAssetReference: string | null;
- previewDistance: number;
- settings: HalftoneStudioSettings;
- shape: ExportedShapeDescriptor;
-};
-
-function getExportedShapeLoader(
- shape: ExportedShapeDescriptor,
-): HalftoneGeometrySpec['loader'] | null {
- if (shape.loader) {
- return shape.loader;
- }
-
- const filename = shape.filename?.toLowerCase() ?? '';
-
- if (filename.endsWith('.fbx')) {
- return 'fbx';
- }
-
- if (filename.endsWith('.glb')) {
- return 'glb';
- }
-
- return null;
-}
-
-function createImportedGeometryRuntimeSource(shape: ExportedShapeDescriptor) {
- if (shape.kind !== 'imported') {
- return '';
- }
-
- const loader = getExportedShapeLoader(shape) ?? 'glb';
-
- return createImportedRuntimeSource(loader);
-}
-
-function toIllustrationRegistryKey(componentName: string) {
- return componentName.charAt(0).toLowerCase() + componentName.slice(1);
-}
-
-function toPublicAssetDestination(assetUrl: string) {
- return assetUrl.startsWith('/') ? `public${assetUrl}` : assetUrl;
-}
-
-function normalizeReactExportSettings(
- exportSettings?: Partial,
-): ReactExportSettings {
- return {
- ...DEFAULT_REACT_EXPORT_SETTINGS,
- ...exportSettings,
- };
-}
-
-function getReactImportBlock(
- exportSettings: ReactExportSettings,
- isImageMode: boolean,
- shape: ExportedShapeDescriptor,
-) {
- const imports = new Set();
-
- imports.add("import { useEffect, useRef, type CSSProperties } from 'react';");
- imports.add("import * as THREE from 'three';");
-
- if (exportSettings.includeStyledMount) {
- imports.add("import { styled } from '@linaria/react';");
- }
-
- if (!isImageMode) {
- imports.add(
- "import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';",
- );
- }
-
- const loader = getExportedShapeLoader(shape);
-
- if (shape.kind === 'imported' && loader === 'fbx') {
- imports.add(
- "import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';",
- );
- }
-
- if (shape.kind === 'imported' && loader === 'glb') {
- imports.add(
- "import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';",
- );
- imports.add(
- "import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';",
- );
- }
-
- return Array.from(imports).join('\n');
-}
-
-function getStandaloneThreeImports(
- isImageMode: boolean,
- shape: ExportedShapeDescriptor,
-) {
- if (isImageMode) {
- return `import * as THREE from 'three';`;
- }
-
- const imports = [
- `import * as THREE from 'three';`,
- `import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';`,
- ];
-
- const loader = getExportedShapeLoader(shape);
-
- if (shape.kind === 'imported' && loader === 'fbx') {
- imports.push(
- `import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';`,
- );
- }
-
- if (shape.kind === 'imported' && loader === 'glb') {
- imports.push(
- `import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';`,
- `import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';`,
- );
- }
-
- return imports.join('\n ');
-}
-
-function getTwentyReactHeaderComment(
- componentName: string,
- registryKey: string,
- assetUrl?: string,
-) {
- const lines = [
- `// Suggested component destination: src/illustrations/${componentName}.tsx`,
- ];
-
- if (assetUrl) {
- lines.push(
- `// Suggested public asset destination: ${toPublicAssetDestination(assetUrl)}`,
- );
- }
-
- lines.push(
- `// illustrations-registry.tsx: import { ${componentName} } from './${componentName}';`,
- `// illustrations-registry.tsx: ${registryKey}: ${componentName},`,
- );
-
- return `${lines.join('\n')}\n`;
-}
-
-function createDefaultExportPose(): HalftoneExportPose {
- return {
- autoElapsed: 0,
- rotateElapsed: 0,
- rotationX: 0,
- rotationY: 0,
- rotationZ: 0,
- targetRotationX: 0,
- targetRotationY: 0,
- timeElapsed: 0,
- };
-}
-
-function normalizeExportPose(
- pose: HalftoneExportPose | undefined,
-): HalftoneExportPose {
- const fallback = createDefaultExportPose();
-
- if (!pose) {
- return fallback;
- }
-
- return {
- autoElapsed: Number.isFinite(pose.autoElapsed) ? pose.autoElapsed : 0,
- rotateElapsed: Number.isFinite(pose.rotateElapsed) ? pose.rotateElapsed : 0,
- rotationX: Number.isFinite(pose.rotationX) ? pose.rotationX : 0,
- rotationY: Number.isFinite(pose.rotationY) ? pose.rotationY : 0,
- rotationZ: Number.isFinite(pose.rotationZ) ? pose.rotationZ : 0,
- targetRotationX: Number.isFinite(pose.targetRotationX)
- ? pose.targetRotationX
- : 0,
- targetRotationY: Number.isFinite(pose.targetRotationY)
- ? pose.targetRotationY
- : 0,
- timeElapsed: Number.isFinite(pose.timeElapsed) ? pose.timeElapsed : 0,
- };
-}
-
-function normalizePreviewDistance(previewDistance: number | undefined) {
- return Number.isFinite(previewDistance)
- ? Math.max(previewDistance ?? REFERENCE_PREVIEW_DISTANCE, 0.001)
- : REFERENCE_PREVIEW_DISTANCE;
-}
-
-function extractSerializedJson(
- content: string,
- name: string,
- nextName: string,
-) {
- const pattern = new RegExp(
- String.raw`const\s+${name}\s*=\s*([\s\S]*?)\s*;\s*\r?\nconst\s+${nextName}\s*=`,
- );
- const match = content.match(pattern);
-
- if (!match) {
- throw new Error(`Could not find ${name} in the exported preset.`);
- }
-
- try {
- return JSON.parse(match[1]) as T;
- } catch (error) {
- throw new Error(
- error instanceof Error
- ? `Could not parse ${name}: ${error.message}`
- : `Could not parse ${name}.`,
- );
- }
-}
-
-function extractFirstMatch(content: string, patterns: RegExp[]) {
- for (const pattern of patterns) {
- const match = content.match(pattern);
-
- if (match && match.length > 1) {
- return match[match.length - 1] ?? null;
- }
- }
-
- return null;
-}
-
-function assertRoundedBandPreset(
- settings: HalftoneStudioSettings,
-): asserts settings is HalftoneStudioSettings {
- if (isRoundedBandHalftoneSettings(settings.halftone)) {
- return;
- }
-
- const halftoneValue =
- settings.halftone && typeof settings.halftone === 'object'
- ? (settings.halftone as Record)
- : null;
- const detectedLegacyKeys = LEGACY_HALFTONE_SETTING_KEYS.filter((key) =>
- halftoneValue ? key in halftoneValue : false,
- );
- const suffix =
- detectedLegacyKeys.length > 0
- ? ` Detected legacy keys: ${detectedLegacyKeys.join(', ')}.`
- : '';
-
- throw new Error(
- `This preset uses the legacy halftone effect and can no longer be imported.${suffix}`,
- );
-}
-
-export function parseExportedPreset(content: string): ParsedExportedPreset {
- const settings = extractSerializedJson(
- content,
- 'settings',
- 'shape',
- );
- assertRoundedBandPreset(settings);
- const shape = extractSerializedJson(
- content,
- 'shape',
- 'initialPose',
- );
- const initialPose = normalizeExportPose(
- (() => {
- try {
- return extractSerializedJson(
- content,
- 'initialPose',
- 'previewDistance',
- );
- } catch {
- return extractSerializedJson(
- content,
- 'initialPose',
- 'VIRTUAL_RENDER_HEIGHT',
- );
- }
- })(),
- );
- const previewDistanceMatch = content.match(
- /const\s+previewDistance\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*;/,
- );
- const previewDistance = normalizePreviewDistance(
- previewDistanceMatch ? Number(previewDistanceMatch[1]) : undefined,
- );
- const componentName =
- extractFirstMatch(content, [
- /export\s+default\s+function\s+([A-Za-z0-9_]+)/,
- /export\s+function\s+([A-Za-z0-9_]+)/,
- /export\s+default\s+([A-Za-z0-9_]+)\s*;/,
- /([^<]+)<\/title>/i,
- ]) ?? null;
- const modelAssetReference = extractFirstMatch(content, [
- /modelUrl\s*=\s*(['"`])([\s\S]*?)\1/,
- /modelUrl\s*:\s*(['"`])([\s\S]*?)\1/,
- ]);
- const imageAssetReference = extractFirstMatch(content, [
- /imageUrl\s*=\s*(['"`])([\s\S]*?)\1/,
- /imageUrl\s*:\s*(['"`])([\s\S]*?)\1/,
- ]);
-
- return {
- componentName,
- imageAssetReference,
- initialPose,
- modelAssetReference,
- previewDistance,
- settings,
- shape,
- };
-}
-
-export function deriveExportComponentName(
- shape: HalftoneGeometrySpec | undefined,
- importedFile: File | undefined,
-) {
- const source =
- importedFile?.name ??
- shape?.filename ??
- shape?.label ??
- shape?.key ??
- 'HalftoneDashes';
-
- return normalizeExportComponentName(source);
-}
-
-function createShapeDescriptor(
- shape: HalftoneGeometrySpec | undefined,
- settings: HalftoneStudioSettings,
- importedFile?: File,
- modelFilenameOverride?: string,
-): ExportedShapeDescriptor {
- if (!shape) {
- return {
- filename: null,
- key: settings.shapeKey,
- kind: 'builtin',
- label: settings.shapeKey,
- loader: null,
- };
- }
-
- const effectiveImportedFilename =
- modelFilenameOverride ?? importedFile?.name ?? shape.filename ?? null;
-
- return {
- filename:
- shape.kind === 'imported'
- ? effectiveImportedFilename
- : (shape.filename ?? null),
- key: shape.key,
- kind: shape.kind,
- label:
- shape.kind === 'imported'
- ? (effectiveImportedFilename ?? shape.label)
- : shape.label,
- loader: shape.loader ?? null,
- };
-}
-
-function getModelMimeType(
- file: File,
- loader: HalftoneGeometrySpec['loader'] | null,
-) {
- if (file.type) {
- return file.type;
- }
-
- if (loader === 'glb' || file.name.toLowerCase().endsWith('.glb')) {
- return 'model/gltf-binary';
- }
-
- if (loader === 'fbx' || file.name.toLowerCase().endsWith('.fbx')) {
- return 'application/octet-stream';
- }
-
- return 'application/octet-stream';
-}
-
-async function fileToDataUrl(
- file: File,
- loader: HalftoneGeometrySpec['loader'] | null,
-) {
- const buffer = await file.arrayBuffer();
- const blob = new Blob([buffer], {
- type: getModelMimeType(file, loader),
- });
-
- return await new Promise((resolve, reject) => {
- const reader = new FileReader();
-
- reader.onerror = () => {
- reject(reader.error ?? new Error(`Unable to read ${file.name}.`));
- };
- reader.onload = () => {
- if (typeof reader.result !== 'string') {
- reject(new Error(`Unable to encode ${file.name}.`));
- return;
- }
-
- resolve(reader.result);
- };
-
- reader.readAsDataURL(blob);
- });
-}
-
-const GLASS_MATERIAL_RUNTIME_SOURCE = String.raw`
-const GLASS_ENVIRONMENT_DATA_URL = ${JSON.stringify(GLASS_ENVIRONMENT_DATA_URL)};
-const GLASS_THICKNESS_TO_WORLD_UNITS = 1 / 320;
-const GLASS_ATTENUATION_DISTANCE_MIN = 0.12;
-const GLASS_ENVIRONMENT_INTENSITY_BASE = 0.18;
-const GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER = 0.12;
-const GLASS_ENVIRONMENT_ZOOM = 1.55;
-const GLASS_TRANSMISSION_BACKGROUND = new THREE.Color(0x030303);
-const MAX_TEXTURE_ANISOTROPY = 8;
-const HALFTONE_TRANSMISSION_SHADER_PREFIX = ${JSON.stringify(
- HALFTONE_TRANSMISSION_SHADER_PREFIX,
-)};
-const HALFTONE_TRANSMISSION_PARS_FRAGMENT = ${JSON.stringify(
- HALFTONE_TRANSMISSION_PARS_FRAGMENT,
-)};
-const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = ${JSON.stringify(
- HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE,
-)};
-
-class HalftoneTransmissionMaterial extends THREE.MeshPhysicalMaterial {
- constructor(samples = 10) {
- super();
-
- this.halftoneUniforms = {
- chromaticAberration: { value: 0.05 },
- transmission: { value: 0 },
- _transmission: { value: 1 },
- transmissionMap: { value: null },
- refractionEnvMap: { value: null },
- useEnvMapRefraction: { value: 0 },
- roughness: { value: 0 },
- thickness: { value: 0 },
- thicknessMap: { value: null },
- attenuationDistance: { value: Infinity },
- attenuationColor: { value: new THREE.Color('white') },
- anisotropicBlur: { value: 0.1 },
- time: { value: 0 },
- distortion: { value: 0 },
- distortionScale: { value: 0.5 },
- temporalDistortion: { value: 0 },
- buffer: { value: null },
- };
-
- this.customProgramCacheKey = () => 'halftone-transmission-' + samples;
-
- this.onBeforeCompile = (shader) => {
- shader.uniforms = {
- ...shader.uniforms,
- ...this.halftoneUniforms,
- };
- shader.defines ??= {};
-
- if (this.anisotropy > 0) {
- shader.defines.USE_ANISOTROPY = '';
- }
-
- shader.defines.USE_TRANSMISSION = '';
- shader.fragmentShader =
- HALFTONE_TRANSMISSION_SHADER_PREFIX + shader.fragmentShader;
- shader.fragmentShader = shader.fragmentShader.replace(
- '#include ',
- HALFTONE_TRANSMISSION_PARS_FRAGMENT,
- );
- shader.fragmentShader = shader.fragmentShader.replace(
- '#include ',
- HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE.replaceAll(
- '__SAMPLES__',
- String(samples),
- ),
- );
- };
-
- Object.keys(this.halftoneUniforms).forEach((key) => {
- Object.defineProperty(this, key, {
- configurable: true,
- enumerable: true,
- get: () => this.halftoneUniforms[key]?.value,
- set: (value) => {
- this.halftoneUniforms[key].value = value;
- },
- });
- });
- }
-}
-
-function setTextureSampling(texture, renderer) {
- texture.generateMipmaps = true;
- texture.magFilter = THREE.LinearFilter;
- texture.minFilter = THREE.LinearMipmapLinearFilter;
- texture.anisotropy = Math.min(
- renderer.capabilities.getMaxAnisotropy(),
- MAX_TEXTURE_ANISOTROPY,
- );
-}
-
-function disposeEnvironmentScene(scene) {
- scene.traverse((object) => {
- if (object.geometry) {
- object.geometry.dispose();
- }
-
- if (Array.isArray(object.material)) {
- object.material.forEach((material) => material.dispose());
- return;
- }
-
- object.material?.dispose?.();
- });
-}
-
-function createSolidEnvironmentTexture(renderer) {
- const pmremGenerator = new THREE.PMREMGenerator(renderer);
- const environmentTexture = pmremGenerator.fromScene(
- new RoomEnvironment(),
- 0.04,
- ).texture;
- pmremGenerator.dispose();
-
- return environmentTexture;
-}
-
-function getTextureImageSize(texture) {
- const image = texture.image;
-
- return {
- height:
- image?.naturalHeight ?? image?.videoHeight ?? image?.height ?? undefined,
- width:
- image?.naturalWidth ?? image?.videoWidth ?? image?.width ?? undefined,
- };
-}
-
-function createZoomedGlassTexture(sourceTexture, renderer, zoom) {
- if (zoom <= 1) {
- return sourceTexture;
- }
-
- const { width, height } = getTextureImageSize(sourceTexture);
-
- if (!width || !height) {
- return sourceTexture;
- }
-
- const canvas = document.createElement('canvas');
- canvas.width = width;
- canvas.height = height;
-
- const context = canvas.getContext('2d');
-
- if (!context) {
- return sourceTexture;
- }
-
- const cropWidth = width / zoom;
- const cropHeight = height / zoom;
- const sourceX = (width - cropWidth) / 2;
- const sourceY = (height - cropHeight) / 2;
-
- context.drawImage(
- sourceTexture.image,
- sourceX,
- sourceY,
- cropWidth,
- cropHeight,
- 0,
- 0,
- width,
- height,
- );
-
- const zoomedTexture = new THREE.CanvasTexture(canvas);
- zoomedTexture.colorSpace = sourceTexture.colorSpace;
- zoomedTexture.wrapS = THREE.ClampToEdgeWrapping;
- zoomedTexture.wrapT = THREE.ClampToEdgeWrapping;
- setTextureSampling(zoomedTexture, renderer);
- zoomedTexture.needsUpdate = true;
-
- return zoomedTexture;
-}
-
-function createStudioGlassEnvironmentTexture(renderer, backdropTexture) {
- const pmremGenerator = new THREE.PMREMGenerator(renderer);
- const environmentTexture = backdropTexture
- ? pmremGenerator.fromEquirectangular(backdropTexture).texture
- : pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture;
- pmremGenerator.dispose();
-
- return environmentTexture;
-}
-
-function createFallbackGlassBackdropTexture(renderer) {
- const texture = new THREE.DataTexture(
- new Uint8Array([3, 3, 3, 255]),
- 1,
- 1,
- THREE.RGBAFormat,
- );
- texture.colorSpace = THREE.SRGBColorSpace;
- texture.wrapS = THREE.ClampToEdgeWrapping;
- texture.wrapT = THREE.ClampToEdgeWrapping;
- texture.mapping = THREE.EquirectangularReflectionMapping;
- setTextureSampling(texture, renderer);
- texture.needsUpdate = true;
-
- return texture;
-}
-
-function loadTexture(url, renderer, colorSpace) {
- const loader = new THREE.TextureLoader();
-
- return new Promise((resolve, reject) => {
- loader.load(
- url,
- (texture) => {
- texture.colorSpace = colorSpace;
- setTextureSampling(texture, renderer);
- resolve(texture);
- },
- undefined,
- reject,
- );
- });
-}
-
-async function loadGlassEnvironmentTexture(renderer) {
- const sourceBackgroundTexture = await loadTexture(
- GLASS_ENVIRONMENT_DATA_URL,
- renderer,
- THREE.SRGBColorSpace,
- );
- const backgroundTexture = createZoomedGlassTexture(
- sourceBackgroundTexture,
- renderer,
- GLASS_ENVIRONMENT_ZOOM,
- );
- if (backgroundTexture !== sourceBackgroundTexture) {
- sourceBackgroundTexture.dispose();
- }
- backgroundTexture.mapping = THREE.EquirectangularReflectionMapping;
- backgroundTexture.wrapS = THREE.ClampToEdgeWrapping;
- backgroundTexture.wrapT = THREE.ClampToEdgeWrapping;
- backgroundTexture.needsUpdate = true;
- const environmentTexture = createStudioGlassEnvironmentTexture(
- renderer,
- backgroundTexture,
- );
-
- return {
- backgroundTexture,
- environmentTexture,
- };
-}
-
-async function createHalftoneMaterialAssets(renderer) {
- const solidEnvironmentTexture = createSolidEnvironmentTexture(renderer);
-
- try {
- const glassEnvironmentAssets = await loadGlassEnvironmentTexture(renderer);
-
- return {
- glassBackgroundTexture: glassEnvironmentAssets.backgroundTexture,
- glassEnvironmentTexture: glassEnvironmentAssets.environmentTexture,
- solidEnvironmentTexture,
- };
- } catch {
- const fallbackGlassBackdropTexture =
- createFallbackGlassBackdropTexture(renderer);
- const fallbackGlassEnvironmentTexture =
- createStudioGlassEnvironmentTexture(renderer);
-
- return {
- glassBackgroundTexture: fallbackGlassBackdropTexture,
- glassEnvironmentTexture: fallbackGlassEnvironmentTexture,
- solidEnvironmentTexture,
- };
- }
-}
-
-function createHalftoneMaterial() {
- return new HalftoneTransmissionMaterial();
-}
-
-function applyHalftoneMaterialSettings(material, materialSettings, materialAssets) {
- const isGlass = materialSettings.surface === 'glass';
- const glassThickness =
- materialSettings.thickness * GLASS_THICKNESS_TO_WORLD_UNITS;
- const glassEnvironmentIntensity =
- GLASS_ENVIRONMENT_INTENSITY_BASE +
- materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER;
- const glassAttenuationDistance = Math.max(
- glassThickness * 4,
- GLASS_ATTENUATION_DISTANCE_MIN,
- );
-
- material.color.set(isGlass ? '#ffffff' : materialSettings.color);
- material.roughness = materialSettings.roughness;
- material.metalness = materialSettings.metalness;
- material.envMap = isGlass
- ? materialAssets.glassEnvironmentTexture
- : materialAssets.solidEnvironmentTexture;
- material.envMapIntensity = isGlass
- ? GLASS_ENVIRONMENT_INTENSITY_BASE +
- materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER
- : 0.25;
- material.clearcoat = isGlass ? 1 : 0;
- material.clearcoatRoughness = isGlass
- ? Math.max(materialSettings.roughness * 0.25, 0.01)
- : 0.08;
- material.reflectivity = isGlass ? 0.98 : 0.5;
- material.transmission = 0;
- material._transmission = isGlass ? 1 : 0;
- material.refractionEnvMap = isGlass ? materialAssets.glassBackgroundTexture : null;
- material.useEnvMapRefraction = isGlass ? 1 : 0;
- material.thickness = isGlass ? glassThickness : 0;
- material.ior = isGlass ? materialSettings.refraction : 1.5;
- material.buffer = null;
- material.bumpMap = null;
- material.bumpScale = 0;
- material.roughnessMap = null;
- material.side = THREE.FrontSide;
- material.transparent = false;
- material.opacity = 1;
- material.depthWrite = true;
- material.attenuationColor.set(isGlass ? materialSettings.color : 'white');
- material.attenuationDistance = isGlass
- ? glassAttenuationDistance
- : Infinity;
- material.anisotropicBlur = isGlass
- ? THREE.MathUtils.lerp(0.03, 0.12, materialSettings.roughness)
- : 0.1;
- material.chromaticAberration = isGlass ? 0 : 0.05;
- material.distortion = 0;
- material.distortionScale = 0.5;
- material.temporalDistortion = 0;
- material.userData.halftoneIsGlass = isGlass;
- material.userData.halftoneGlassBacksideThickness = isGlass
- ? glassThickness * 2
- : 0;
- material.userData.halftoneGlassBacksideEnvIntensity = isGlass
- ? glassEnvironmentIntensity * 2.8
- : 0;
- material.userData.halftoneUseEnvironmentRefraction = isGlass;
- material.envMapIntensity = isGlass ? glassEnvironmentIntensity : 0.25;
-
- material.needsUpdate = true;
-}
-
-function disposeHalftoneMaterialAssets(materialAssets) {
- materialAssets.glassBackgroundTexture.dispose();
-
- if (materialAssets.glassEnvironmentTexture !== materialAssets.glassBackgroundTexture) {
- materialAssets.glassEnvironmentTexture.dispose();
- }
-
- materialAssets.solidEnvironmentTexture.dispose();
-}
-`;
-
-function serializeRuntimeSource(
- settings: HalftoneStudioSettings,
- shape: ExportedShapeDescriptor,
- initialPose: HalftoneExportPose,
- previewDistance: number | undefined,
-) {
- const isImageMode = settings.sourceMode === 'image';
- const normalizedPreviewDistance = normalizePreviewDistance(previewDistance);
-
- return `
-const settings = ${JSON.stringify(settings, null, 2)};
-const shape = ${JSON.stringify(shape, null, 2)};
-const initialPose = ${JSON.stringify(initialPose, null, 2)};
-const previewDistance = ${JSON.stringify(normalizedPreviewDistance)};
-const VIRTUAL_RENDER_HEIGHT = ${VIRTUAL_RENDER_HEIGHT};
-const passThroughVertexShader = ${JSON.stringify(passThroughVertexShader)};
-const blurFragmentShader = ${JSON.stringify(blurFragmentShader)};
-const halftoneFragmentShader = ${JSON.stringify(halftoneFragmentShader)};
-${isImageMode ? `const imagePassthroughFragmentShader = ${JSON.stringify(imagePassthroughFragmentShader)};` : ''}
-
-${HALFTONE_FOOTPRINT_RUNTIME_SOURCE}
-
-${isImageMode ? '' : GEOMETRY_RUNTIME_SOURCE}
-
-${isImageMode ? '' : createImportedGeometryRuntimeSource(shape)}
-
-${isImageMode ? '' : GLASS_MATERIAL_RUNTIME_SOURCE}
-
-function createRenderTarget(width, height) {
- return new THREE.WebGLRenderTarget(width, height, {
- minFilter: THREE.LinearFilter,
- magFilter: THREE.LinearFilter,
- format: THREE.RGBAFormat,
- });
-}
-
-function createInteractionState() {
- return {
- autoElapsed: initialPose.autoElapsed,
- activePointerId: null,
- dragging: false,
- hoverStrength: 0,
- mouseX: 0.5,
- mouseY: 0.5,
- pointerInside: false,
- pointerVelocityX: 0,
- pointerVelocityY: 0,
- pointerX: 0,
- pointerY: 0,
- rotateElapsed: initialPose.rotateElapsed,
- rotationX: initialPose.rotationX,
- rotationVelocityX: 0,
- rotationY: initialPose.rotationY,
- rotationVelocityY: 0,
- rotationZ: initialPose.rotationZ,
- rotationVelocityZ: 0,
- smoothedMouseX: 0.5,
- smoothedMouseY: 0.5,
- targetRotationX: initialPose.targetRotationX,
- targetRotationY: initialPose.targetRotationY,
- velocityX: 0,
- velocityY: 0,
- };
-}
-
-${
- isImageMode
- ? ''
- : `
-function setPrimaryLightPosition(light, angleDegrees, height) {
- const lightAngle = (angleDegrees * Math.PI) / 180;
- light.position.set(Math.cos(lightAngle) * 5, height, Math.sin(lightAngle) * 5);
-}
-
-function applySpringStep(current, target, velocity, strength, damping) {
- const nextVelocity = (velocity + (target - current) * strength) * damping;
- const nextValue = current + nextVelocity;
-
- return {
- value: nextValue,
- velocity: nextVelocity,
- };
-}
-
-function resetInteractionState(interactionState) {
- interactionState.dragging = false;
- interactionState.mouseX = 0.5;
- interactionState.mouseY = 0.5;
- interactionState.targetRotationX = 0;
- interactionState.targetRotationY = 0;
- interactionState.velocityX = 0;
- interactionState.velocityY = 0;
- interactionState.rotationVelocityX = 0;
- interactionState.rotationVelocityY = 0;
- interactionState.rotationVelocityZ = 0;
- interactionState.autoElapsed = 0;
-}
-
-async function createGeometry(modelUrl) {
- if (shape.kind === 'imported') {
- if (!modelUrl) {
- throw new Error('No model URL was provided for ' + shape.label + '.');
- }
-
- return loadImportedGeometryFromUrl(modelUrl, shape.label);
- }
-
- return createBuiltinGeometry(shape.key);
-}
-`
-}
-`;
-}
-
-function createMountScript() {
- return `
-async function mountHalftoneCanvas(options) {
- const {
- container,
- modelUrl,
- onError,
- } = options;
-
- 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,
- );
-
- let geometry;
-
- try {
- geometry = await createGeometry(modelUrl);
- } catch (error) {
- onError?.(error);
- return () => {};
- }
-
- // boundary-allow-next-line:no-raw-webgl-renderer -- emitted into the standalone HTML export; runs in the user's downloaded file with no access to lib/visual-runtime
- const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
- renderer.outputColorSpace = THREE.SRGBColorSpace;
- renderer.setPixelRatio(1);
- renderer.setClearColor(0x000000, 0);
- renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
-
- const canvas = renderer.domElement;
- canvas.style.cursor = settings.animation.followDragEnabled ? 'grab' : 'default';
- canvas.style.display = 'block';
- canvas.style.height = '100%';
- canvas.style.touchAction = 'none';
- canvas.style.width = '100%';
- container.appendChild(canvas);
-
- const materialAssets = await createHalftoneMaterialAssets(renderer);
-
- const scene3d = new THREE.Scene();
- scene3d.background = null;
-
- const baseCameraDistance = previewDistance;
- const camera = new THREE.PerspectiveCamera(45, getWidth() / getHeight(), 0.1, 100);
- camera.position.z = baseCameraDistance;
-
- const primaryLight = new THREE.DirectionalLight(0xffffff, settings.lighting.intensity);
- setPrimaryLightPosition(
- primaryLight,
- settings.lighting.angleDegrees,
- settings.lighting.height,
- );
- scene3d.add(primaryLight);
-
- const fillLight = new THREE.DirectionalLight(
- 0xffffff,
- settings.lighting.fillIntensity,
- );
- fillLight.position.set(-3, -1, 1);
- scene3d.add(fillLight);
-
- const ambientLight = new THREE.AmbientLight(
- 0xffffff,
- settings.lighting.ambientIntensity,
- );
- scene3d.add(ambientLight);
-
- const material = createHalftoneMaterial();
- applyHalftoneMaterialSettings(material, settings.material, materialAssets);
-
- const mesh = new THREE.Mesh(geometry, material);
- scene3d.add(mesh);
-
- const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
- const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
- const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
- const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
- const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
-
- const blurHorizontalMaterial = new THREE.ShaderMaterial({
- uniforms: {
- tInput: { value: null },
- dir: { value: new THREE.Vector2(1, 0) },
- res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: blurFragmentShader,
- });
-
- const blurVerticalMaterial = new THREE.ShaderMaterial({
- uniforms: {
- tInput: { value: null },
- dir: { value: new THREE.Vector2(0, 1) },
- res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: blurFragmentShader,
- });
-
- const halftoneMaterial = new THREE.ShaderMaterial({
- transparent: true,
- uniforms: {
- tScene: { value: sceneTarget.texture },
- tGlow: { value: blurTargetB.texture },
- effectResolution: {
- value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
- },
- logicalResolution: {
- value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
- },
- tile: { value: settings.halftone.scale },
- s_3: { value: settings.halftone.power },
- s_4: { value: settings.halftone.width },
- applyToDarkAreas: {
- value: settings.halftone.toneTarget === 'dark' ? 1 : 0,
- },
- dashColor: { value: new THREE.Color(settings.halftone.dashColor) },
- hoverDashColor: {
- value: new THREE.Color(settings.halftone.hoverDashColor),
- },
- time: { value: 0 },
- waveAmount: { value: 0 },
- waveSpeed: { value: 1 },
- footprintScale: { value: 1.0 },
- interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
- interactionVelocity: { value: new THREE.Vector2(0, 0) },
- dragOffset: { value: new THREE.Vector2(0, 0) },
- hoverHalftoneActive: { value: 0 },
- hoverHalftonePowerShift: { value: 0 },
- hoverHalftoneRadius: { value: 0.2 },
- hoverHalftoneWidthShift: { value: 0 },
- hoverLightStrength: { value: 0 },
- hoverLightRadius: { value: 0.2 },
- hoverFlowStrength: { value: 0 },
- hoverFlowRadius: { value: 0.18 },
- dragFlowStrength: { value: 0 },
- cropToBounds: { value: 0 },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: halftoneFragmentShader,
- });
-
- const blurHorizontalScene = new THREE.Scene();
- blurHorizontalScene.add(
- new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
- );
-
- const blurVerticalScene = new THREE.Scene();
- blurVerticalScene.add(new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial));
-
- const postScene = new THREE.Scene();
- postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
-
- const updateViewportUniforms = (
- logicalWidth,
- logicalHeight,
- effectWidth,
- effectHeight,
- ) => {
- blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
- blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
- halftoneMaterial.uniforms.effectResolution.value.set(
- effectWidth,
- effectHeight,
- );
- halftoneMaterial.uniforms.logicalResolution.value.set(
- logicalWidth,
- logicalHeight,
- );
- };
-
- const getHalftoneScale = (viewportWidth, viewportHeight, lookAtTarget) => {
- if (!mesh.geometry.boundingBox) {
- mesh.geometry.computeBoundingBox();
- }
-
- if (!mesh.geometry.boundingBox) {
- return 1;
- }
-
- mesh.updateMatrixWorld();
- camera.updateMatrixWorld();
-
- return getMeshFootprintScale({
- camera,
- localBounds: mesh.geometry.boundingBox,
- lookAtTarget,
- meshMatrixWorld: mesh.matrixWorld,
- viewportHeight,
- viewportWidth,
- });
- };
-
- const interaction = createInteractionState();
- const autoRotateEnabled = settings.animation.autoRotateEnabled;
- const followHoverEnabled = settings.animation.followHoverEnabled;
- const followDragEnabled = settings.animation.followDragEnabled;
- const rotateEnabled = settings.animation.rotateEnabled;
-
- const syncSize = () => {
- const width = getWidth();
- const height = getHeight();
- const virtualWidth = getVirtualWidth();
- const virtualHeight = getVirtualHeight();
-
- renderer.setSize(virtualWidth, virtualHeight, false);
- camera.aspect = width / height;
- camera.updateProjectionMatrix();
- sceneTarget.setSize(virtualWidth, virtualHeight);
- blurTargetA.setSize(virtualWidth, virtualHeight);
- blurTargetB.setSize(virtualWidth, virtualHeight);
- updateViewportUniforms(
- virtualWidth,
- virtualHeight,
- virtualWidth,
- virtualHeight,
- );
- };
-
- const resizeObserver = new ResizeObserver(syncSize);
- resizeObserver.observe(container);
-
- const updatePointerPosition = (event) => {
- const rect = canvas.getBoundingClientRect();
- const width = Math.max(rect.width, 1);
- const height = Math.max(rect.height, 1);
-
- interaction.mouseX = THREE.MathUtils.clamp(
- (event.clientX - rect.left) / width,
- 0,
- 1,
- );
- interaction.mouseY = THREE.MathUtils.clamp(
- (event.clientY - rect.top) / height,
- 0,
- 1,
- );
- };
-
- const handlePointerDown = (event) => {
- updatePointerPosition(event);
- if (!followDragEnabled) {
- return;
- }
-
- interaction.dragging = true;
- interaction.pointerX = event.clientX;
- interaction.pointerY = event.clientY;
- interaction.velocityX = 0;
- interaction.velocityY = 0;
- canvas.style.cursor = 'grabbing';
- };
-
- const handlePointerMove = (event) => {
- updatePointerPosition(event);
- };
-
- const handleWindowPointerMove = (event) => {
- updatePointerPosition(event);
-
- if (!interaction.dragging || !followDragEnabled) {
- return;
- }
-
- const deltaX = (event.clientX - interaction.pointerX) * settings.animation.dragSens;
- const deltaY = (event.clientY - interaction.pointerY) * settings.animation.dragSens;
- interaction.velocityX = deltaY;
- interaction.velocityY = deltaX;
- interaction.targetRotationY += deltaX;
- interaction.targetRotationX += deltaY;
- interaction.pointerX = event.clientX;
- interaction.pointerY = event.clientY;
- };
-
- const handlePointerLeave = () => {
- if (interaction.dragging) {
- return;
- }
-
- interaction.mouseX = 0.5;
- interaction.mouseY = 0.5;
- };
-
- const handlePointerUp = () => {
- interaction.dragging = false;
- canvas.style.cursor = followDragEnabled ? 'grab' : 'default';
-
- if (!settings.animation.springReturnEnabled) {
- return;
- }
-
- const springImpulse = Math.max(settings.animation.springStrength * 10, 1.2);
- interaction.rotationVelocityX += interaction.velocityX * springImpulse;
- interaction.rotationVelocityY += interaction.velocityY * springImpulse;
- interaction.rotationVelocityZ += interaction.velocityY * springImpulse * 0.12;
- interaction.targetRotationX = 0;
- interaction.targetRotationY = 0;
- interaction.velocityX = 0;
- interaction.velocityY = 0;
- };
-
- const handlePointerCancel = () => {
- interaction.dragging = false;
- interaction.velocityX = 0;
- interaction.velocityY = 0;
- canvas.style.cursor = followDragEnabled ? 'grab' : 'default';
- handlePointerLeave();
- };
-
- const handleWindowBlur = () => {
- handlePointerUp();
- handlePointerLeave();
- };
-
- canvas.addEventListener('pointermove', handlePointerMove);
- canvas.addEventListener('pointerleave', handlePointerLeave);
- canvas.addEventListener('pointercancel', handlePointerCancel);
- window.addEventListener('pointerup', handlePointerUp);
- window.addEventListener('pointermove', handleWindowPointerMove);
- window.addEventListener('blur', handleWindowBlur);
- canvas.addEventListener('pointerdown', handlePointerDown);
-
- const clock = new THREE.Timer();
- clock.connect(document);
- let animationFrameId = 0;
-
- const renderFrame = (timestamp) => {
- animationFrameId = window.requestAnimationFrame(renderFrame);
- clock.update(timestamp);
-
- const delta = 1 / 60;
- const elapsedTime = initialPose.timeElapsed + clock.getElapsed();
- halftoneMaterial.uniforms.time.value = elapsedTime;
-
- let baseRotationX = initialPose.rotationX;
- let baseRotationY = initialPose.rotationY;
- let baseRotationZ = initialPose.rotationZ;
- let meshOffsetY = 0;
- let meshScale = 1;
- let lightAngle = settings.lighting.angleDegrees;
- let lightHeight = settings.lighting.height;
-
- if (autoRotateEnabled) {
- interaction.autoElapsed += delta;
- baseRotationY += interaction.autoElapsed * settings.animation.autoSpeed;
- baseRotationX += Math.sin(interaction.autoElapsed * 0.2) * settings.animation.autoWobble;
- }
-
- if (settings.animation.floatEnabled) {
- const floatPhase = elapsedTime * settings.animation.floatSpeed;
- const driftAmount = (settings.animation.driftAmount * Math.PI) / 180;
-
- meshOffsetY += Math.sin(floatPhase) * settings.animation.floatAmplitude;
- baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45;
- baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3;
- }
-
- if (settings.animation.breatheEnabled) {
- meshScale *=
- 1 +
- Math.sin(elapsedTime * settings.animation.breatheSpeed) *
- settings.animation.breatheAmount;
- }
-
- if (rotateEnabled) {
- interaction.rotateElapsed += delta;
- const rotateProgress = settings.animation.rotatePingPong
- ? Math.sin(interaction.rotateElapsed * settings.animation.rotateSpeed) * Math.PI
- : interaction.rotateElapsed * settings.animation.rotateSpeed;
-
- if (settings.animation.rotatePreset === 'axis') {
- const axisDirection =
- settings.animation.rotateAxis.startsWith('-') ? -1 : 1;
- const axisProgress = rotateProgress * axisDirection;
-
- if (
- settings.animation.rotateAxis === 'x' ||
- settings.animation.rotateAxis === 'xy' ||
- settings.animation.rotateAxis === '-x' ||
- settings.animation.rotateAxis === '-xy'
- ) {
- baseRotationX += axisProgress;
- }
-
- if (
- settings.animation.rotateAxis === 'y' ||
- settings.animation.rotateAxis === 'xy' ||
- settings.animation.rotateAxis === '-y' ||
- settings.animation.rotateAxis === '-xy'
- ) {
- baseRotationY += axisProgress;
- }
-
- if (
- settings.animation.rotateAxis === 'z' ||
- settings.animation.rotateAxis === '-z'
- ) {
- baseRotationZ += axisProgress;
- }
- } else if (settings.animation.rotatePreset === 'lissajous') {
- baseRotationX += Math.sin(rotateProgress * 0.85) * 0.65;
- baseRotationY += Math.sin(rotateProgress * 1.35 + 0.8) * 1.05;
- baseRotationZ += Math.sin(rotateProgress * 0.55 + 1.6) * 0.32;
- } else if (settings.animation.rotatePreset === 'orbit') {
- baseRotationX += Math.sin(rotateProgress * 0.75) * 0.42;
- baseRotationY += Math.cos(rotateProgress) * 1.2;
- baseRotationZ += Math.sin(rotateProgress * 1.25) * 0.24;
- } else if (settings.animation.rotatePreset === 'tumble') {
- baseRotationX += rotateProgress * 0.55;
- baseRotationY += Math.sin(rotateProgress * 0.8) * 0.9;
- baseRotationZ += Math.cos(rotateProgress * 1.1) * 0.38;
- }
- }
-
- if (settings.animation.lightSweepEnabled) {
- const lightPhase = elapsedTime * settings.animation.lightSweepSpeed;
- lightAngle += Math.sin(lightPhase) * settings.animation.lightSweepRange;
- lightHeight +=
- Math.cos(lightPhase * 0.85) *
- settings.animation.lightSweepHeightRange;
- }
-
- let targetX = baseRotationX;
- let targetY = baseRotationY;
- let easing = 0.12;
-
- if (followHoverEnabled) {
- const rangeRadians = (settings.animation.hoverRange * Math.PI) / 180;
-
- if (
- settings.animation.hoverReturn ||
- interaction.mouseX !== 0.5 ||
- interaction.mouseY !== 0.5
- ) {
- targetX += (interaction.mouseY - 0.5) * rangeRadians;
- targetY += (interaction.mouseX - 0.5) * rangeRadians;
- }
-
- easing = settings.animation.hoverEase;
- }
-
- if (followDragEnabled) {
- if (!interaction.dragging && settings.animation.dragMomentum) {
- interaction.targetRotationX += interaction.velocityX;
- interaction.targetRotationY += interaction.velocityY;
- interaction.velocityX *= 1 - settings.animation.dragFriction;
- interaction.velocityY *= 1 - settings.animation.dragFriction;
- }
-
- targetX += interaction.targetRotationX;
- targetY += interaction.targetRotationY;
- easing = settings.animation.dragFriction;
- }
-
- if (autoRotateEnabled && !followHoverEnabled && !followDragEnabled) {
- targetX = baseRotationX + interaction.targetRotationX;
- targetY = baseRotationY + interaction.targetRotationY;
-
- if (interaction.dragging) {
- targetX = interaction.targetRotationX;
- targetY = interaction.targetRotationY;
- }
-
- easing = 0.08;
- }
-
- if (settings.animation.springReturnEnabled) {
- const springX = applySpringStep(
- interaction.rotationX,
- targetX,
- interaction.rotationVelocityX,
- settings.animation.springStrength,
- settings.animation.springDamping,
- );
- const springY = applySpringStep(
- interaction.rotationY,
- targetY,
- interaction.rotationVelocityY,
- settings.animation.springStrength,
- settings.animation.springDamping,
- );
- const springZ = applySpringStep(
- interaction.rotationZ,
- baseRotationZ,
- interaction.rotationVelocityZ,
- settings.animation.springStrength,
- settings.animation.springDamping,
- );
-
- interaction.rotationX = springX.value;
- interaction.rotationY = springY.value;
- interaction.rotationZ = springZ.value;
- interaction.rotationVelocityX = springX.velocity;
- interaction.rotationVelocityY = springY.velocity;
- interaction.rotationVelocityZ = springZ.velocity;
- } else {
- interaction.rotationX += (targetX - interaction.rotationX) * easing;
- interaction.rotationY += (targetY - interaction.rotationY) * easing;
- interaction.rotationZ +=
- (baseRotationZ - interaction.rotationZ) *
- (settings.animation.rotatePingPong ? 0.18 : 0.12);
- }
-
- mesh.rotation.set(
- interaction.rotationX,
- interaction.rotationY,
- interaction.rotationZ,
- );
- mesh.position.y = meshOffsetY;
- mesh.scale.setScalar(meshScale);
-
- if (settings.animation.cameraParallaxEnabled) {
- const cameraRange = settings.animation.cameraParallaxAmount;
- const cameraEase = settings.animation.cameraParallaxEase;
- const centeredX = (interaction.mouseX - 0.5) * 2;
- const centeredY = (0.5 - interaction.mouseY) * 2;
- const orbitYaw = centeredX * cameraRange;
- const orbitPitch = centeredY * cameraRange * 0.7;
- const horizontalRadius = Math.cos(orbitPitch) * baseCameraDistance;
- const targetCameraX = Math.sin(orbitYaw) * horizontalRadius;
- const targetCameraY =
- Math.sin(orbitPitch) * baseCameraDistance * 0.85;
- const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius;
-
- camera.position.x += (targetCameraX - camera.position.x) * cameraEase;
- camera.position.y += (targetCameraY - camera.position.y) * cameraEase;
- camera.position.z += (targetCameraZ - camera.position.z) * cameraEase;
- } else {
- camera.position.x += (0 - camera.position.x) * 0.12;
- camera.position.y += (0 - camera.position.y) * 0.12;
- camera.position.z += (baseCameraDistance - camera.position.z) * 0.12;
- }
-
- const lookAtTarget = new THREE.Vector3(0, meshOffsetY * 0.2, 0);
-
- camera.lookAt(lookAtTarget);
- setPrimaryLightPosition(primaryLight, lightAngle, lightHeight);
- halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale(
- getVirtualWidth(),
- getVirtualHeight(),
- lookAtTarget,
- );
-
- if (!settings.halftone.enabled) {
- renderer.setRenderTarget(null);
- renderer.clear();
- renderer.render(scene3d, camera);
- return;
- }
-
- renderer.setRenderTarget(sceneTarget);
- renderer.render(scene3d, camera);
-
- blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
- renderer.setRenderTarget(blurTargetA);
- renderer.render(blurHorizontalScene, orthographicCamera);
-
- blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
- renderer.setRenderTarget(blurTargetB);
- renderer.render(blurVerticalScene, orthographicCamera);
-
- blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
- renderer.setRenderTarget(blurTargetA);
- renderer.render(blurHorizontalScene, orthographicCamera);
-
- blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
- renderer.setRenderTarget(blurTargetB);
- renderer.render(blurVerticalScene, orthographicCamera);
-
- renderer.setRenderTarget(null);
- renderer.clear();
- renderer.render(postScene, orthographicCamera);
- };
-
- renderFrame();
-
- return () => {
- window.cancelAnimationFrame(animationFrameId);
- clock.dispose();
- resizeObserver.disconnect();
- canvas.removeEventListener('pointermove', handlePointerMove);
- canvas.removeEventListener('pointerleave', handlePointerLeave);
- window.removeEventListener('pointerup', handlePointerUp);
- window.removeEventListener('pointermove', handleWindowPointerMove);
- canvas.removeEventListener('pointercancel', handlePointerCancel);
- window.removeEventListener('blur', handleWindowBlur);
- canvas.removeEventListener('pointerdown', handlePointerDown);
- blurHorizontalMaterial.dispose();
- blurVerticalMaterial.dispose();
- halftoneMaterial.dispose();
- fullScreenGeometry.dispose();
- material.dispose();
- sceneTarget.dispose();
- blurTargetA.dispose();
- blurTargetB.dispose();
- disposeHalftoneMaterialAssets(materialAssets);
- renderer.dispose();
-
- if (canvas.parentNode === container) {
- container.removeChild(canvas);
- }
- };
-}
-`;
-}
-
-function createImageMountScript() {
- return `
-async function mountHalftoneCanvas(options) {
- const {
- container,
- imageUrl,
- onError,
- } = options;
-
- 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 image = await new Promise((resolve, reject) => {
- const img = new Image();
- img.onload = () => resolve(img);
- img.onerror = () => reject(new Error('Failed to load image'));
- img.src = imageUrl;
- });
-
- // boundary-allow-next-line:no-raw-webgl-renderer -- emitted into the standalone HTML export; runs in the user's downloaded file with no access to lib/visual-runtime
- const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
- renderer.outputColorSpace = THREE.SRGBColorSpace;
- renderer.setPixelRatio(1);
- renderer.setClearColor(0x000000, 0);
- renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
-
- const canvas = renderer.domElement;
- canvas.style.cursor = 'default';
- canvas.style.display = 'block';
- canvas.style.height = '100%';
- canvas.style.touchAction = 'none';
- canvas.style.width = '100%';
- container.appendChild(canvas);
-
- const imageTexture = new THREE.Texture(image);
- imageTexture.colorSpace = THREE.SRGBColorSpace;
- imageTexture.needsUpdate = true;
-
- const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
- const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
- const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
- const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
- const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
-
- const imageMaterial = new THREE.ShaderMaterial({
- uniforms: {
- tImage: { value: imageTexture },
- imageSize: { value: new THREE.Vector2(image.width, image.height) },
- viewportSize: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
- zoom: { value: getImagePreviewZoom(previewDistance) },
- contrast: { value: settings.halftone.imageContrast },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: imagePassthroughFragmentShader,
- });
-
- const imageScene = new THREE.Scene();
- imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
-
- const blurHorizontalMaterial = new THREE.ShaderMaterial({
- uniforms: {
- tInput: { value: null },
- dir: { value: new THREE.Vector2(1, 0) },
- res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: blurFragmentShader,
- });
-
- const blurVerticalMaterial = new THREE.ShaderMaterial({
- uniforms: {
- tInput: { value: null },
- dir: { value: new THREE.Vector2(0, 1) },
- res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: blurFragmentShader,
- });
-
- const halftoneMaterial = new THREE.ShaderMaterial({
- transparent: true,
- uniforms: {
- tScene: { value: sceneTarget.texture },
- tGlow: { value: blurTargetB.texture },
- effectResolution: {
- value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
- },
- logicalResolution: {
- value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
- },
- tile: { value: settings.halftone.scale },
- s_3: { value: settings.halftone.power },
- s_4: { value: settings.halftone.width },
- applyToDarkAreas: {
- value: settings.halftone.toneTarget === 'dark' ? 1 : 0,
- },
- dashColor: { value: new THREE.Color(settings.halftone.dashColor) },
- hoverDashColor: {
- value: new THREE.Color(settings.halftone.hoverDashColor),
- },
- time: { value: 0 },
- waveAmount: { value: 0 },
- waveSpeed: { value: settings.animation.waveSpeed },
- footprintScale: { value: 1.0 },
- interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
- interactionVelocity: { value: new THREE.Vector2(0, 0) },
- dragOffset: { value: new THREE.Vector2(0, 0) },
- hoverHalftoneActive: { value: 0 },
- hoverHalftonePowerShift: { value: 0 },
- hoverHalftoneRadius: { value: settings.animation.hoverHalftoneRadius },
- hoverHalftoneWidthShift: { value: 0 },
- hoverLightStrength: { value: 0 },
- hoverLightRadius: { value: settings.animation.hoverLightRadius },
- hoverFlowStrength: { value: 0 },
- hoverFlowRadius: { value: 0.18 },
- dragFlowStrength: { value: 0 },
- cropToBounds: { value: 1 },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: halftoneFragmentShader,
- });
-
- const blurHorizontalScene = new THREE.Scene();
- blurHorizontalScene.add(new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial));
-
- const blurVerticalScene = new THREE.Scene();
- blurVerticalScene.add(new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial));
-
- const postScene = new THREE.Scene();
- postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
-
- const updateViewportUniforms = (
- logicalWidth,
- logicalHeight,
- effectWidth,
- effectHeight,
- ) => {
- blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
- blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
- halftoneMaterial.uniforms.effectResolution.value.set(
- effectWidth,
- effectHeight,
- );
- halftoneMaterial.uniforms.logicalResolution.value.set(
- logicalWidth,
- logicalHeight,
- );
- imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
- };
-
- const getHalftoneScale = () =>
- getImageFootprintScale({
- imageHeight: image.height,
- imageWidth: image.width,
- previewDistance,
- viewportHeight: getVirtualHeight(),
- viewportWidth: getVirtualWidth(),
- });
-
- const interaction = createInteractionState();
- const imagePointerFollow = 0.38;
- const imagePointerVelocityDamping = 0.82;
- const imageHoverFadeIn = 18;
- const imageHoverFadeOut = 7;
-
- const syncSize = () => {
- const virtualWidth = getVirtualWidth();
- const virtualHeight = getVirtualHeight();
-
- renderer.setSize(virtualWidth, virtualHeight, false);
- sceneTarget.setSize(virtualWidth, virtualHeight);
- blurTargetA.setSize(virtualWidth, virtualHeight);
- blurTargetB.setSize(virtualWidth, virtualHeight);
- updateViewportUniforms(
- virtualWidth,
- virtualHeight,
- virtualWidth,
- virtualHeight,
- );
- };
-
- const resizeObserver = new ResizeObserver(syncSize);
- resizeObserver.observe(container);
-
- const updatePointerPosition = (event, options = {}) => {
- const rect = canvas.getBoundingClientRect();
- const width = Math.max(rect.width, 1);
- const height = Math.max(rect.height, 1);
-
- const nextMouseX = THREE.MathUtils.clamp(
- (event.clientX - rect.left) / width, 0, 1,
- );
- const nextMouseY = THREE.MathUtils.clamp(
- (event.clientY - rect.top) / height, 0, 1,
- );
-
- const deltaX = nextMouseX - interaction.mouseX;
- const deltaY = nextMouseY - interaction.mouseY;
-
- interaction.mouseX = nextMouseX;
- interaction.mouseY = nextMouseY;
- interaction.pointerInside =
- interaction.dragging ||
- (event.clientX >= rect.left &&
- event.clientX <= rect.right &&
- event.clientY >= rect.top &&
- event.clientY <= rect.bottom);
-
- if (options.resetVelocity) {
- interaction.pointerVelocityX = 0;
- interaction.pointerVelocityY = 0;
- interaction.smoothedMouseX = nextMouseX;
- interaction.smoothedMouseY = nextMouseY;
- } else {
- interaction.pointerVelocityX = deltaX;
- interaction.pointerVelocityY = deltaY;
- }
-
- return { deltaX, deltaY };
- };
-
- const releasePointerCapture = (pointerId) => {
- if (pointerId === null) {
- return;
- }
-
- if (!canvas.hasPointerCapture(pointerId)) {
- return;
- }
-
- try {
- canvas.releasePointerCapture(pointerId);
- } catch (error) {
- void error;
- }
- };
-
- const handlePointerDown = (event) => {
- updatePointerPosition(event, { resetVelocity: true });
- interaction.pointerX = event.clientX;
- interaction.pointerY = event.clientY;
- };
-
- const handlePointerMove = (event) => {
- const resetVelocity = !interaction.pointerInside && !interaction.dragging;
- updatePointerPosition(
- event,
- resetVelocity ? { resetVelocity: true } : undefined,
- );
- };
-
- const handlePointerLeave = () => {
- if (interaction.dragging) {
- return;
- }
-
- interaction.pointerInside = false;
- interaction.pointerVelocityX = 0;
- interaction.pointerVelocityY = 0;
- };
-
- const handlePointerUp = (event) => {
- updatePointerPosition(event, { resetVelocity: true });
- releasePointerCapture(interaction.activePointerId);
- interaction.activePointerId = null;
- interaction.dragging = false;
- const rect = canvas.getBoundingClientRect();
- interaction.pointerInside =
- event.clientX >= rect.left &&
- event.clientX <= rect.right &&
- event.clientY >= rect.top &&
- event.clientY <= rect.bottom;
- };
-
- const handlePointerCancel = () => {
- releasePointerCapture(interaction.activePointerId);
- interaction.activePointerId = null;
- interaction.dragging = false;
- interaction.pointerInside = false;
- interaction.pointerVelocityX = 0;
- interaction.pointerVelocityY = 0;
- };
-
- const handleWindowBlur = () => {
- handlePointerCancel();
- };
-
- canvas.addEventListener('pointermove', handlePointerMove);
- canvas.addEventListener('pointerleave', handlePointerLeave);
- canvas.addEventListener('pointerup', handlePointerUp);
- canvas.addEventListener('pointercancel', handlePointerCancel);
- window.addEventListener('blur', handleWindowBlur);
- canvas.addEventListener('pointerdown', handlePointerDown);
-
- const clock = new THREE.Timer();
- clock.connect(document);
- let animationFrameId = 0;
-
- const renderFrame = (timestamp) => {
- animationFrameId = window.requestAnimationFrame(renderFrame);
- clock.update(timestamp);
-
- const deltaSeconds = clock.getDelta();
- const elapsedTime = clock.getElapsed();
- halftoneMaterial.uniforms.time.value = elapsedTime;
- const hoverEasing =
- 1 -
- Math.exp(
- -deltaSeconds *
- (interaction.pointerInside ? imageHoverFadeIn : imageHoverFadeOut),
- );
- interaction.hoverStrength +=
- ((interaction.pointerInside ? 1 : 0) - interaction.hoverStrength) *
- hoverEasing;
-
- interaction.smoothedMouseX +=
- (interaction.mouseX - interaction.smoothedMouseX) * imagePointerFollow;
- interaction.smoothedMouseY +=
- (interaction.mouseY - interaction.smoothedMouseY) * imagePointerFollow;
- interaction.pointerVelocityX *= imagePointerVelocityDamping;
- interaction.pointerVelocityY *= imagePointerVelocityDamping;
-
- halftoneMaterial.uniforms.interactionUv.value.set(
- interaction.smoothedMouseX,
- 1 - interaction.smoothedMouseY,
- );
- halftoneMaterial.uniforms.interactionVelocity.value.set(
- interaction.pointerVelocityX * getVirtualWidth(),
- -interaction.pointerVelocityY * getVirtualHeight(),
- );
- halftoneMaterial.uniforms.dragOffset.value.set(0, 0);
- halftoneMaterial.uniforms.hoverHalftoneActive.value =
- settings.animation.hoverHalftoneEnabled ? interaction.hoverStrength : 0;
- halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
- settings.animation.hoverHalftoneEnabled
- ? settings.animation.hoverHalftonePowerShift
- : 0;
- halftoneMaterial.uniforms.hoverHalftoneRadius.value =
- settings.animation.hoverHalftoneRadius;
- halftoneMaterial.uniforms.hoverHalftoneWidthShift.value =
- settings.animation.hoverHalftoneEnabled
- ? settings.animation.hoverHalftoneWidthShift
- : 0;
- halftoneMaterial.uniforms.hoverLightStrength.value =
- settings.animation.hoverLightEnabled
- ? settings.animation.hoverLightIntensity * interaction.hoverStrength
- : 0;
- halftoneMaterial.uniforms.hoverLightRadius.value =
- settings.animation.hoverLightRadius;
- halftoneMaterial.uniforms.hoverFlowStrength.value = 0;
- halftoneMaterial.uniforms.hoverFlowRadius.value = 0.18;
- halftoneMaterial.uniforms.dragFlowStrength.value = 0;
- imageMaterial.uniforms.zoom.value = getImagePreviewZoom(previewDistance);
- halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
-
- renderer.setRenderTarget(sceneTarget);
- renderer.render(imageScene, orthographicCamera);
-
- blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
- renderer.setRenderTarget(blurTargetA);
- renderer.render(blurHorizontalScene, orthographicCamera);
-
- blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
- renderer.setRenderTarget(blurTargetB);
- renderer.render(blurVerticalScene, orthographicCamera);
-
- blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
- renderer.setRenderTarget(blurTargetA);
- renderer.render(blurHorizontalScene, orthographicCamera);
-
- blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
- renderer.setRenderTarget(blurTargetB);
- renderer.render(blurVerticalScene, orthographicCamera);
-
- renderer.setRenderTarget(null);
- renderer.clear();
- renderer.render(postScene, orthographicCamera);
- };
-
- renderFrame();
-
- return () => {
- window.cancelAnimationFrame(animationFrameId);
- clock.dispose();
- resizeObserver.disconnect();
- canvas.removeEventListener('pointermove', handlePointerMove);
- canvas.removeEventListener('pointerleave', handlePointerLeave);
- canvas.removeEventListener('pointerup', handlePointerUp);
- canvas.removeEventListener('pointercancel', handlePointerCancel);
- window.removeEventListener('blur', handleWindowBlur);
- canvas.removeEventListener('pointerdown', handlePointerDown);
- blurHorizontalMaterial.dispose();
- blurVerticalMaterial.dispose();
- halftoneMaterial.dispose();
- imageMaterial.dispose();
- imageTexture.dispose();
- fullScreenGeometry.dispose();
- sceneTarget.dispose();
- blurTargetA.dispose();
- blurTargetB.dispose();
- renderer.dispose();
-
- if (canvas.parentNode === container) {
- container.removeChild(canvas);
- }
- };
-}
-`;
-}
-
-export function getExportedModelFile(
- shape: HalftoneGeometrySpec | undefined,
- importedFile: File | undefined,
-) {
- if (!shape || shape.kind !== 'imported' || !importedFile) {
- return null;
- }
-
- return importedFile;
-}
-
-export function generateReactComponent(
- settings: HalftoneStudioSettings,
- selectedShape: HalftoneGeometrySpec | undefined,
- componentName = 'HalftoneDashes',
- options: ReactExportOptions = {},
-) {
- const isImageMode = settings.sourceMode === 'image';
- const exportSettings = normalizeReactExportSettings(options.exportSettings);
- const shape = createShapeDescriptor(
- selectedShape,
- settings,
- options.importedFile,
- options.modelFilenameOverride,
- );
- const pose = normalizeExportPose(options.initialPose);
- const normalizedComponentName = normalizeExportComponentName(componentName);
- const background = options.background ?? 'transparent';
- const assetUrl =
- exportSettings.includePublicAssetUrl && options.assetUrl
- ? options.assetUrl
- : null;
- const defaultModelFilename =
- options.modelFilenameOverride ?? shape.filename ?? 'model.glb';
- const defaultImageFilename = options.imageFilename ?? 'image.png';
- const defaultModelUrl = assetUrl ?? `./${defaultModelFilename}`;
- const defaultImageUrl = assetUrl ?? `./${defaultImageFilename}`;
- const importBlock = getReactImportBlock(exportSettings, isImageMode, shape);
- const headerComment = exportSettings.includeRegistryComment
- ? getTwentyReactHeaderComment(
- normalizedComponentName,
- toIllustrationRegistryKey(normalizedComponentName),
- isImageMode || shape.kind === 'imported'
- ? (assetUrl ?? undefined)
- : undefined,
- )
- : '';
- const mountScript = isImageMode
- ? createImageMountScript()
- : createMountScript();
- const serializedRuntime = serializeRuntimeSource(
- settings,
- shape,
- pose,
- options.previewDistance,
- );
- const generatedBanner = exportSettings.includeTsNoCheck
- ? `/**
- * @generated halftone-studio
- * This file is autogenerated by the in-app halftone studio
- * (src/app/halftone). Do NOT hand-edit — regenerate the export
- * from the studio if the visual or runtime behaviour needs to change.
- *
- * Type checking is disabled at the file level because the serialized
- * Three.js runtime below contains thousands of lines of legacy
- * implicitly-typed code that the generator does not annotate. See
- * src/app/halftone/_lib/exporters.ts for the generator source.
- */
-// @ts-nocheck`
- : null;
- const directiveLines = [
- generatedBanner,
- exportSettings.includeUseClientDirective ? "'use client';" : null,
- ]
- .filter((line): line is string => line !== null)
- .join('\n');
- const mountStyleBlock = exportSettings.includeStyledMount
- ? `const StyledVisualMount = styled.div\`
- background: ${background};
- display: block;
- height: 100%;
- min-width: 0;
- width: 100%;
-\`;
-`
- : '';
- const assetPropName = isImageMode
- ? 'imageUrl'
- : shape.kind === 'imported'
- ? 'modelUrl'
- : null;
- const assetPropDefaultValue =
- assetPropName === 'imageUrl'
- ? defaultImageUrl
- : assetPropName === 'modelUrl'
- ? defaultModelUrl
- : null;
- const propsTypeBlock = assetPropName
- ? `type ${normalizedComponentName}Props = {\n ${assetPropName}?: string;\n style?: CSSProperties;\n};`
- : `type ${normalizedComponentName}Props = {\n style?: CSSProperties;\n};`;
- const propsSignature = assetPropName
- ? `{\n ${assetPropName} = ${JSON.stringify(assetPropDefaultValue)},\n style,\n}: ${normalizedComponentName}Props`
- : `{\n style,\n}: ${normalizedComponentName}Props`;
- const mountOptionsBlock = assetPropName
- ? `const unmount = mountHalftoneCanvas({\n container,\n ${assetPropName},\n onError: (error) => {\n console.error(error);\n },\n });`
- : `const unmount = mountHalftoneCanvas({\n container,\n onError: (error) => {\n console.error(error);\n },\n });`;
- const effectDependencies = assetPropName ? `[${assetPropName}]` : '[]';
- const returnBlock = exportSettings.includeStyledMount
- ? `return ;`
- : `return (
-
- );`;
- const componentFunctionBlock = exportSettings.includeNamedAndDefaultExport
- ? `export function ${normalizedComponentName}(${propsSignature}) {
- const mountReference = useRef(null);
-
- useEffect(() => {
- const container = mountReference.current;
-
- if (!container) {
- return;
- }
-
- ${mountOptionsBlock}
-
- return () => {
- void Promise.resolve(unmount).then((dispose) => dispose?.());
- };
- }, ${effectDependencies});
-
- ${returnBlock}
-}
-
-export default ${normalizedComponentName};`
- : `export default function ${normalizedComponentName}(${propsSignature}) {
- const mountReference = useRef(null);
-
- useEffect(() => {
- const container = mountReference.current;
-
- if (!container) {
- return;
- }
-
- ${mountOptionsBlock}
-
- return () => {
- void Promise.resolve(unmount).then((dispose) => dispose?.());
- };
- }, ${effectDependencies});
-
- ${returnBlock}
-}`;
-
- return `${directiveLines ? `${directiveLines}\n\n` : ''}${importBlock}
-
-${headerComment}${serializedRuntime}
-
-${mountScript}
-
-${mountStyleBlock ? `${mountStyleBlock}\n` : ''}${propsTypeBlock}
-
-${componentFunctionBlock}
-`;
-}
-
-export async function generateStandaloneHtml(
- settings: HalftoneStudioSettings,
- selectedShape: HalftoneGeometrySpec | undefined,
- componentName = 'HalftoneDashes',
- options: ReactExportOptions = {},
-) {
- const isImageMode = settings.sourceMode === 'image';
- const shape = createShapeDescriptor(
- selectedShape,
- settings,
- options.importedFile,
- options.modelFilenameOverride,
- );
- const pose = normalizeExportPose(options.initialPose);
- const normalizedComponentName = normalizeExportComponentName(componentName);
- const defaultImageUrl = options.imageFilename ?? 'image.png';
- const embeddedImportedModelUrl =
- !isImageMode && shape.kind === 'imported' && options.importedFile
- ? await fileToDataUrl(options.importedFile, shape.loader)
- : null;
- const defaultModelUrl =
- embeddedImportedModelUrl ??
- options.modelFilenameOverride ??
- shape.filename ??
- 'model.glb';
- const background = options.background ?? 'transparent';
-
- const threeImports = getStandaloneThreeImports(isImageMode, shape);
-
- const mountScript = isImageMode
- ? createImageMountScript()
- : createMountScript();
-
- const mountCall = isImageMode
- ? `mountHalftoneCanvas({
- container,
- imageUrl: ${JSON.stringify(`./${defaultImageUrl}`)},
- onError: (error) => {
- console.error(error);
- },
- });`
- : `mountHalftoneCanvas({
- container,
- modelUrl: ${JSON.stringify(`./${defaultModelUrl}`)},
- onError: (error) => {
- console.error(error);
- },
- });`;
-
- const captionText = isImageMode
- ? `Place ${defaultImageUrl} next to this HTML file.`
- : `Standalone export of the current halftone scene.
- ${
- shape.kind === 'imported'
- ? embeddedImportedModelUrl
- ? 'The uploaded model is embedded directly in this HTML file.'
- : `Place ${defaultModelUrl} next to this HTML file to keep the current uploaded shape.`
- : ''
- }`;
-
- return `
-
-
-
-
- ${normalizedComponentName}
-
-
-
-
-
-
-
-
-
-`;
-}
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/share.ts b/packages/twenty-website-new/src/app/[locale]/halftone/_lib/share.ts
deleted file mode 100644
index ea741c4c7e..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/share.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import {
- DEFAULT_GEOMETRY_SPECS,
- DEFAULT_HALFTONE_SETTINGS,
- normalizeHalftoneStudioSettings,
- type HalftoneStudioSettings,
-} from '@/lib/halftone/state';
-
-export type ShareableHalftoneState = {
- settings: HalftoneStudioSettings;
- previewDistance: number;
- exportName: string;
-};
-
-function toUrlSafeBase64(input: string) {
- return input.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
-}
-
-function fromUrlSafeBase64(input: string) {
- let normalized = input.replace(/-/g, '+').replace(/_/g, '/');
-
- while (normalized.length % 4 !== 0) {
- normalized += '=';
- }
-
- return normalized;
-}
-
-function encodeUtf8ToBase64(value: string) {
- const bytes = new TextEncoder().encode(value);
- let binary = '';
-
- bytes.forEach((byte) => {
- binary += String.fromCharCode(byte);
- });
-
- return btoa(binary);
-}
-
-function decodeBase64ToUtf8(value: string) {
- const binary = atob(value);
- const bytes = new Uint8Array(binary.length);
-
- for (let index = 0; index < binary.length; index += 1) {
- bytes[index] = binary.charCodeAt(index);
- }
-
- return new TextDecoder().decode(bytes);
-}
-
-export function encodeShareState(state: ShareableHalftoneState): string {
- const payload = {
- settings: state.settings,
- previewDistance: state.previewDistance,
- exportName: state.exportName,
- };
-
- return toUrlSafeBase64(encodeUtf8ToBase64(JSON.stringify(payload)));
-}
-
-function sanitizeShapeKey(shapeKey: string): string {
- const isBuiltinShape = DEFAULT_GEOMETRY_SPECS.some(
- (spec) => spec.key === shapeKey,
- );
-
- return isBuiltinShape ? shapeKey : DEFAULT_HALFTONE_SETTINGS.shapeKey;
-}
-
-export function decodeShareState(
- hash: string | null | undefined,
-): ShareableHalftoneState | null {
- if (!hash) {
- return null;
- }
-
- const trimmed = hash.replace(/^#/, '').trim();
-
- if (!trimmed) {
- return null;
- }
-
- try {
- const json = decodeBase64ToUtf8(fromUrlSafeBase64(trimmed));
- const parsed = JSON.parse(json) as Partial | null;
-
- if (!parsed || typeof parsed !== 'object') {
- return null;
- }
-
- const settings = normalizeHalftoneStudioSettings(parsed.settings);
- const previewDistance =
- typeof parsed.previewDistance === 'number' &&
- Number.isFinite(parsed.previewDistance)
- ? parsed.previewDistance
- : null;
- const exportName =
- typeof parsed.exportName === 'string' ? parsed.exportName : '';
-
- if (previewDistance === null) {
- return null;
- }
-
- return {
- settings: {
- ...settings,
- shapeKey: sanitizeShapeKey(settings.shapeKey),
- },
- previewDistance,
- exportName,
- };
- } catch {
- return null;
- }
-}
-
-export function buildShareUrl(state: ShareableHalftoneState): string {
- const hash = encodeShareState(state);
- const { origin, pathname } = window.location;
-
- return `${origin}${pathname}#${hash}`;
-}
diff --git a/packages/twenty-website-new/src/app/[locale]/layout.tsx b/packages/twenty-website-new/src/app/[locale]/layout.tsx
index 54d9313bec..e5d34fcad7 100644
--- a/packages/twenty-website-new/src/app/[locale]/layout.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/layout.tsx
@@ -4,7 +4,7 @@ import {
getSiteUrl,
JsonLd,
} from '@/lib/seo';
-import { DRACO_DECODER_ORIGIN } from '@/lib/visual-runtime/draco-decoder-path';
+import { DRACO_DECODER_ORIGIN } from '@/lib/visual-runtime/utils/draco-decoder-path';
import { theme } from '@/theme';
import { cssVariables } from '@/theme/css-variables';
import { css } from '@linaria/core';
@@ -15,19 +15,17 @@ import { type ReactNode } from 'react';
import { FooterVisibilityGate } from '@/app/_components/FooterVisibilityGate';
import { ScrollToTopOnRouteChange } from '@/app/_components/ScrollToTopOnRouteChange';
-import { ContactCalModalRoot } from '@/lib/contact-cal';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
import {
I18nProvider,
- PUBLIC_APP_LOCALE_LIST,
localeToUrlSegment,
+ PUBLIC_APP_LOCALE_LIST,
resolveLocaleParam,
} from '@/lib/i18n';
-import { getLocaleMessages } from '@/lib/i18n/messages-by-locale';
-import { setServerI18n } from '@/lib/i18n/set-server-i18n';
-import { PartnerApplicationModalRoot } from '@/lib/partner-application';
-import { Footer } from '@/sections/Footer/components';
-import { FOOTER_DATA } from '@/sections/Footer/data';
+import { getLocaleMessages } from '@/lib/i18n/utils/messages-by-locale';
+import { setServerI18n } from '@/lib/i18n/utils/set-server-i18n';
+import { ContactCalModalRoot } from '@/sections/ContactCal';
+import { Footer, FOOTER_DATA } from '@/sections/Footer';
+import { PartnerApplicationModalRoot } from '@/sections/PartnerApplication';
const hostGrotesk = Host_Grotesk({
subsets: ['latin'],
@@ -114,6 +112,11 @@ export const metadata: Metadata = {
site: '@twentycrm',
creator: '@twentycrm',
},
+ alternates: {
+ types: {
+ 'application/rss+xml': '/articles/feed.xml',
+ },
+ },
};
type LocaleLayoutParams = { locale: string };
@@ -134,8 +137,7 @@ const LocaleLayout = async ({
}) => {
const { locale: rawLocale } = await params;
const locale = resolveLocaleParam(rawLocale);
- const i18n = setServerI18n(locale);
- const renderText = createMessageDescriptorRenderer(i18n);
+ setServerI18n(locale);
const messages = getLocaleMessages(locale);
return (
@@ -162,14 +164,10 @@ const LocaleLayout = async ({
-
+
diff --git a/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/BecomePartnerButton.tsx b/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/BecomePartnerButton.tsx
index c3d7c525db..20047f6a3c 100644
--- a/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/BecomePartnerButton.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/BecomePartnerButton.tsx
@@ -4,8 +4,8 @@ import {
BaseButton,
buttonBaseStyles,
} from '@/design-system/components/Button/BaseButton';
-import { useRenderMessage } from '@/lib/i18n/use-render-message';
-import { usePartnerApplicationModal } from '@/lib/partner-application';
+import { useLingui } from '@lingui/react';
+import { usePartnerApplicationModal } from '@/sections/PartnerApplication';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { styled } from '@linaria/react';
@@ -25,7 +25,7 @@ export function BecomePartnerButton({
label = msg`Become a partner`,
variant = 'contained',
}: BecomePartnerButtonProps) {
- const renderText = useRenderMessage();
+ const { i18n } = useLingui();
const { openPartnerApplicationModal } = usePartnerApplicationModal();
return (
@@ -37,7 +37,7 @@ export function BecomePartnerButton({
openPartnerApplicationModal();
}}
>
-
+
);
}
diff --git a/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerHeroCtas.tsx b/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerHeroCtas.tsx
index 94b395327a..93906abdd9 100644
--- a/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerHeroCtas.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerHeroCtas.tsx
@@ -1,6 +1,6 @@
'use client';
-import { TalkToUsButton } from '@/lib/contact-cal';
+import { TalkToUsButton } from '@/sections/ContactCal';
import { msg } from '@lingui/core/macro';
import { BecomePartnerButton } from './BecomePartnerButton';
diff --git a/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerSignoffCtas.tsx b/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerSignoffCtas.tsx
index b103637dfe..29f45d6913 100644
--- a/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerSignoffCtas.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/partners/components/PartnerApplication/PartnerSignoffCtas.tsx
@@ -1,6 +1,6 @@
'use client';
-import { TalkToUsButton } from '@/lib/contact-cal';
+import { TalkToUsButton } from '@/sections/ContactCal';
import { msg } from '@lingui/core/macro';
import { BecomePartnerButton } from './BecomePartnerButton';
diff --git a/packages/twenty-website-new/src/app/[locale]/partners/hero.data.ts b/packages/twenty-website-new/src/app/[locale]/partners/hero.data.ts
deleted file mode 100644
index fff36e4389..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/partners/hero.data.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { msg } from '@lingui/core/macro';
-
-export const HERO_COPY = {
- body: msg`We're building the #1 Open Source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.`,
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/partners/page.tsx b/packages/twenty-website-new/src/app/[locale]/partners/page.tsx
index d2e1c695a1..13ea4fab88 100644
--- a/packages/twenty-website-new/src/app/[locale]/partners/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/partners/page.tsx
@@ -1,41 +1,36 @@
import { msg } from '@lingui/core/macro';
-import { FAQ_DATA } from '@/sections/Faq/data';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
-import { TalkToUsButton } from '@/lib/contact-cal';
+import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
+import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
+import { TalkToUsButton } from '@/sections/ContactCal';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
-import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/partners/three-cards-illustration.data';
-import { HERO_COPY } from '@/app/[locale]/partners/hero.data';
-import { SIGNOFF_COPY } from '@/app/[locale]/partners/signoff.data';
-import { TESTIMONIALS_DATA } from '@/app/[locale]/partners/testimonials.data';
+import { PARTNER_ILLUSTRATION_CARDS } from '@/app/[locale]/partners/three-cards-illustration.data';
+import { PARTNER_TESTIMONIALS } from '@/app/[locale]/partners/testimonials.data';
import {
PartnerHeroCtas,
PartnerSignoffCtas,
} from '@/app/[locale]/partners/components/PartnerApplication';
import {
- Body,
Eyebrow,
Heading,
HeadingPart,
LinkButton,
} from '@/design-system/components';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
+} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog/components';
-import { Faq } from '@/sections/Faq/components';
-import { Hero } from '@/sections/Hero/components';
-import { Menu } from '@/sections/Menu/components';
-import { Signoff } from '@/sections/Signoff/components';
-import { Testimonials } from '@/sections/Testimonials/components';
-import { ThreeCards } from '@/sections/ThreeCards/components';
-import { TrustedBy } from '@/sections/TrustedBy/components';
-import type { ThreeCardsScrollLayoutOptions } from '@/sections/ThreeCards/utils/three-cards-scroll-layout';
+import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog';
+import { Hero } from '@/sections/Hero';
+import { Menu, MENU_DATA } from '@/sections/Menu';
+import { Signoff } from '@/sections/Signoff';
+import { Testimonials } from '@/sections/Testimonials';
+import {
+ ThreeCards,
+ type ThreeCardsScrollLayoutOptions,
+} from '@/sections/ThreeCards';
import { theme } from '@/theme';
import { buildRouteMetadata } from '@/lib/seo';
import { styled } from '@linaria/react';
@@ -68,7 +63,6 @@ export default async function PartnerPage({ params }: PartnerPageProps) {
getRouteI18n(params),
fetchCommunityStats(),
]);
- const renderText = createMessageDescriptorRenderer(i18n);
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
@@ -85,21 +79,19 @@ export default async function PartnerPage({ params }: PartnerPageProps) {
-
+
-
- {renderText(msg`Become`)}
-
+ {i18n._(msg`Become`)}
- {renderText(msg`our partner`)}
+ {i18n._(msg`our partner`)}
-
+
+ {i18n._(
+ msg`We're building the #1 Open Source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.`,
+ )}
+
@@ -109,88 +101,68 @@ export default async function PartnerPage({ params }: PartnerPageProps) {
-
-
-
-
+ separator={i18n._(msg`trusted by`)}
+ logos={TRUSTED_BY_LOGOS}
+ clientCount={i18n._(msg`+10k others`)}
+ />
-
+
-
+
+
+ {i18n._(msg`Which partner program is right for you?`)}
+
+
- {renderText(msg`Find the program that fits your business`)}
+ {i18n._(msg`Find the program that fits your business`)}
{' '}
- {renderText(msg`and unlock new opportunities with Twenty`)}
+ {i18n._(msg`and unlock new opportunities with Twenty`)}
- {THREE_CARDS_ILLUSTRATION_DATA.body && (
-
- )}
-
+
- {renderText(msg`Ready to grow`)}
+ {i18n._(msg`Ready to grow`)}
- {renderText(msg`with Twenty?`)}
+ {i18n._(msg`with Twenty?`)}
-
+
+ {i18n._(
+ msg`Join our partner ecosystem and help businesses\ntake control of their CRM.`,
+ )}
+
@@ -198,25 +170,25 @@ export default async function PartnerPage({ params }: PartnerPageProps) {
-
+
+
+ {i18n._(msg`Any Questions?`)}
+
+
- {renderText(msg`Stop fighting custom.`)}
+ {i18n._(msg`Stop fighting custom.`)}
- {renderText(msg`Start building, with Twenty`)}
+ {i18n._(msg`Start building, with Twenty`)}
-
+
>
);
diff --git a/packages/twenty-website-new/src/app/[locale]/partners/signoff.data.ts b/packages/twenty-website-new/src/app/[locale]/partners/signoff.data.ts
deleted file mode 100644
index c35eebf9ca..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/partners/signoff.data.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { msg } from '@lingui/core/macro';
-
-export const SIGNOFF_COPY = {
- body: msg`Join our partner ecosystem and help businesses\ntake control of their CRM.`,
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/partners/testimonials.data.ts b/packages/twenty-website-new/src/app/[locale]/partners/testimonials.data.ts
index a0d7bd4a46..1e2f55d995 100644
--- a/packages/twenty-website-new/src/app/[locale]/partners/testimonials.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/partners/testimonials.data.ts
@@ -1,55 +1,38 @@
import { msg } from '@lingui/core/macro';
-import type { TestimonialsDataType } from '@/sections/Testimonials/types';
+import type { TestimonialCardType } from '@/sections/Testimonials';
-export const TESTIMONIALS_DATA: TestimonialsDataType = {
- eyebrow: {
- heading: {
- text: msg`Join our growing partner ecosystem`,
- fontFamily: 'sans',
+export const PARTNER_TESTIMONIALS: TestimonialCardType[] = [
+ {
+ heading: msg`Twenty gives you the kind of flexibility that actually changes what you can offer your clients. The dev experience is clean, the APIs are open, and when something needs to be customized, you can just do it. There's no fighting the platform.`,
+ author: {
+ name: msg`Benjamin Reynolds`,
+ designation: msg`Principal and Founder, Alternative Partners`,
+ avatar: {
+ src: '/images/partner/testimonials/benjamin-reynolds.webp',
+ alt: 'Portrait of Benjamin Reynolds',
+ },
},
},
- testimonials: [
- {
- heading: {
- text: msg`Twenty gives you the kind of flexibility that actually changes what you can offer your clients. The dev experience is clean, the APIs are open, and when something needs to be customized, you can just do it. There's no fighting the platform.`,
- fontFamily: 'sans',
- },
- author: {
- name: { text: msg`Benjamin Reynolds` },
- designation: { text: msg`Principal and Founder, Alternative Partners` },
- avatar: {
- src: '/images/partner/testimonials/benjamin-reynolds.webp',
- alt: 'Portrait of Benjamin Reynolds',
- },
+ {
+ heading: msg`The flexibility is just amazing. Literally, there's nothing you cannot do. You can create objects, access everything through the API, pull notes and send them to the portal. Try doing that in HubSpot. No way. It's the true ability to build exactly what's actually needed.`,
+ author: {
+ name: msg`Bertrams`,
+ designation: msg`Founder, Wintactix`,
+ avatar: {
+ src: '/images/partner/testimonials/bertrams.webp',
+ alt: 'Portrait of Bertrams',
},
},
- {
- heading: {
- text: msg`The flexibility is just amazing. Literally, there's nothing you cannot do. You can create objects, access everything through the API, pull notes and send them to the portal. Try doing that in HubSpot. No way. It's the true ability to build exactly what's actually needed.`,
- fontFamily: 'sans',
- },
- author: {
- name: { text: msg`Bertrams` },
- designation: { text: msg`Founder, Wintactix` },
- avatar: {
- src: '/images/partner/testimonials/bertrams.jpeg',
- alt: 'Portrait of Bertrams',
- },
+ },
+ {
+ heading: msg`Twenty Apps opens the door to building products, not just implementations. For example, we're developing a WhatsApp Business integration that any Twenty's client could get. That's a recurring revenue stream we wouldn't have if we were just configuring someone else's platform.`,
+ author: {
+ name: msg`Mike Babiy`,
+ designation: msg`Founder, Nine Dots Ventures`,
+ avatar: {
+ src: '/images/partner/testimonials/mike-babiy.webp',
+ alt: 'Photo featuring Mike Babiy',
},
},
- {
- heading: {
- text: msg`Twenty Apps opens the door to building products, not just implementations. For example, we're developing a WhatsApp Business integration that any Twenty’s client could get. That's a recurring revenue stream we wouldn't have if we were just configuring someone else's platform.`,
- fontFamily: 'sans',
- },
- author: {
- name: { text: msg`Mike Babiy` },
- designation: { text: msg`Founder, Nine Dots Ventures` },
- avatar: {
- src: '/images/partner/testimonials/mike-babiy.png',
- alt: 'Photo featuring Mike Babiy',
- },
- },
- },
- ],
-};
+ },
+];
diff --git a/packages/twenty-website-new/src/app/[locale]/partners/three-cards-illustration.data.ts b/packages/twenty-website-new/src/app/[locale]/partners/three-cards-illustration.data.ts
index 5ce6cf3b9a..20e840ec07 100644
--- a/packages/twenty-website-new/src/app/[locale]/partners/three-cards-illustration.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/partners/three-cards-illustration.data.ts
@@ -1,70 +1,56 @@
import { msg } from '@lingui/core/macro';
-import type { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
+import type { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards';
-export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
- eyebrow: {
- heading: {
- text: msg`Which partner program is right for you?`,
- fontFamily: 'sans',
+export const PARTNER_ILLUSTRATION_CARDS: ThreeCardsIllustrationCardType[] = [
+ {
+ heading: msg`Technology Partners`,
+ body: msg`Build integrations that connect Twenty with the tools your customers already use. Help us expand the Twenty ecosystem.`,
+ benefits: [
+ { text: msg`Co-marketing opportunities`, icon: 'users' },
+ { text: msg`Listing on Twenty integrations page`, icon: 'search' },
+ { text: msg`Soon: earn revenue`, icon: 'tag' },
+ ],
+ action: {
+ kind: 'partnerApplication',
+ label: msg`Become a Technology Partner`,
+ programId: 'technology',
},
+ attribution: undefined,
+ illustration: 'programming',
},
- illustrationCards: [
- {
- heading: { text: msg`Technology Partners`, fontFamily: 'sans' },
- body: {
- text: msg`Build integrations that connect Twenty with the tools your customers already use. Help us expand the Twenty ecosystem.`,
+ {
+ heading: msg`Content & Community Partners`,
+ body: msg`Share Twenty with your audience and help shape the future of the #1 Open Source CRM. We're looking for creators, educators, and community builders who want to showcase great software.`,
+ benefits: [
+ { text: msg`Revenue share for referred customers`, icon: 'tag' },
+ {
+ text: msg`Exclusive content collaboration opportunities`,
+ icon: 'edit',
},
- benefits: [
- { text: msg`Co-marketing opportunities`, icon: 'users' },
- { text: msg`Listing on Twenty integrations page`, icon: 'search' },
- { text: msg`Soon: earn revenue`, icon: 'tag' },
- ],
- action: {
- kind: 'partnerApplication',
- label: msg`Become a Technology Partner`,
- programId: 'technology',
- },
- attribution: undefined,
- illustration: 'programming',
+ { text: msg`Marketing assets & brand resources`, icon: 'book' },
+ ],
+ action: {
+ kind: 'partnerApplication',
+ label: msg`Become a Content Partner`,
+ programId: 'content',
},
- {
- heading: { text: msg`Content & Community Partners`, fontFamily: 'sans' },
- body: {
- text: msg`Share Twenty with your audience and help shape the future of the #1 Open Source CRM. We're looking for creators, educators, and community builders who want to showcase great software.`,
- },
- benefits: [
- { text: msg`Revenue share for referred customers`, icon: 'tag' },
- {
- text: msg`Exclusive content collaboration opportunities`,
- icon: 'edit',
- },
- { text: msg`Marketing assets & brand resources`, icon: 'book' },
- ],
- action: {
- kind: 'partnerApplication',
- label: msg`Become a Content Partner`,
- programId: 'content',
- },
- attribution: undefined,
- illustration: 'connect',
+ attribution: undefined,
+ illustration: 'connect',
+ },
+ {
+ heading: msg`Solutions Partners`,
+ body: msg`Help customers implement, customize, and succeed with Twenty. Combine sales and services to grow your business.`,
+ benefits: [
+ { text: msg`Resale discounts & revenue share`, icon: 'tag' },
+ { text: msg`Marketplace listing`, icon: 'search' },
+ { text: msg`Dedicated partner support`, icon: 'users' },
+ ],
+ action: {
+ kind: 'partnerApplication',
+ label: msg`Become a Solution Partner`,
+ programId: 'solutions',
},
- {
- heading: { text: msg`Solutions Partners`, fontFamily: 'sans' },
- body: {
- text: msg`Help customers implement, customize, and succeed with Twenty. Combine sales and services to grow your business.`,
- },
- benefits: [
- { text: msg`Resale discounts & revenue share`, icon: 'tag' },
- { text: msg`Marketplace listing`, icon: 'search' },
- { text: msg`Dedicated partner support`, icon: 'users' },
- ],
- action: {
- kind: 'partnerApplication',
- label: msg`Become a Solution Partner`,
- programId: 'solutions',
- },
- attribution: undefined,
- illustration: 'grow',
- },
- ],
-};
+ attribution: undefined,
+ illustration: 'grow',
+ },
+];
diff --git a/packages/twenty-website-new/src/app/[locale]/pricing/engagement-band.data.ts b/packages/twenty-website-new/src/app/[locale]/pricing/engagement-band.data.ts
deleted file mode 100644
index b516b399f4..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/pricing/engagement-band.data.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { msg } from '@lingui/core/macro';
-
-export const ENGAGEMENT_BAND_COPY = {
- body: msg`Find the right partner to implement, customize, and tailor Twenty to your team.`,
- heading: msg`Need help with customization?`,
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/pricing/hero.data.ts b/packages/twenty-website-new/src/app/[locale]/pricing/hero.data.ts
deleted file mode 100644
index be0371d4c8..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/pricing/hero.data.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { msg } from '@lingui/core/macro';
-
-export const HERO_COPY = {
- body: msg`Start your free trial today\nwithout credit card.`,
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/pricing/page.tsx b/packages/twenty-website-new/src/app/[locale]/pricing/page.tsx
index 604aa68569..7a000410fa 100644
--- a/packages/twenty-website-new/src/app/[locale]/pricing/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/pricing/page.tsx
@@ -1,29 +1,23 @@
import { msg } from '@lingui/core/macro';
-import { FAQ_DATA } from '@/sections/Faq/data';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { TalkToUsButton } from '@/lib/contact-cal';
+import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
+import { TalkToUsButton } from '@/sections/ContactCal';
import { BecomePartnerButton } from '@/app/[locale]/partners/components/PartnerApplication';
-import { ENGAGEMENT_BAND_COPY } from '@/app/[locale]/pricing/engagement-band.data';
-import { HERO_COPY } from '@/app/[locale]/pricing/hero.data';
import { PLAN_TABLE_DATA } from '@/app/[locale]/pricing/plan-table.data';
import { SALESFORCE_DATA } from '@/app/[locale]/pricing/salesforce.data';
import { Eyebrow, HeadingPart, LinkButton } from '@/design-system/components';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
+} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { EngagementBand } from '@/sections/EngagementBand/components';
-import { Faq } from '@/sections/Faq/components';
-import { Hero } from '@/sections/Hero/components';
-import { Menu } from '@/sections/Menu/components';
-import { Plans } from '@/sections/Plans/components';
-import { PricingStateProvider } from '@/sections/Plans/context/PricingStateContext';
-import { PlanTable } from '@/sections/PlanTable/components';
-import { Salesforce } from '@/sections/Salesforce/components';
+import { EngagementBand } from '@/sections/EngagementBand';
+import { Hero } from '@/sections/Hero';
+import { Menu, MENU_DATA } from '@/sections/Menu';
+import { Plans, PricingStateProvider } from '@/sections/Plans';
+import { PlanTable } from '@/sections/PlanTable';
+import { Salesforce } from '@/sections/Salesforce';
import { theme } from '@/theme';
import { buildFaqPageJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo';
import { styled } from '@linaria/react';
@@ -51,12 +45,11 @@ export default async function PricingPage({ params }: PricingPageProps) {
getRouteI18n(params),
fetchCommunityStats(),
]);
- const renderText = createMessageDescriptorRenderer(i18n);
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
<>
-
+ i18n._(d))} />
-
+
-
- {renderText(msg`Simple`)}
-
+ {i18n._(msg`Simple`)}
-
- {renderText(msg`Pricing`)}
-
+ {i18n._(msg`Pricing`)}
-
+
+ {i18n._(msg`Start your free trial today\nwithout credit card.`)}
+
-
+
-
+
-
-
+
+
+ {i18n._(msg`Need help with customization?`)}
+
+
+
+ {i18n._(
+ msg`Find the right partner to implement, customize, and tailor Twenty to your team.`,
+ )}
+
-
+
- {renderText(msg`Trust the n°1 CRM,`)}
+ {i18n._(msg`Trust the n°1 CRM,`)}
{' '}
- {renderText(msg`or not !`)}
+ {i18n._(msg`or not !`)}
-
+
+
+ {i18n._(msg`Any Questions?`)}
+
+
- {renderText(msg`Stop fighting custom.`)}
+ {i18n._(msg`Stop fighting custom.`)}
- {renderText(msg`Start building, with Twenty`)}
+ {i18n._(msg`Start building, with Twenty`)}
-
+
>
);
diff --git a/packages/twenty-website-new/src/app/[locale]/pricing/plan-table.data.ts b/packages/twenty-website-new/src/app/[locale]/pricing/plan-table.data.ts
index f944671fa6..d1d0b8a8f8 100644
--- a/packages/twenty-website-new/src/app/[locale]/pricing/plan-table.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/pricing/plan-table.data.ts
@@ -1,5 +1,5 @@
import { msg } from '@lingui/core/macro';
-import type { PlanTableDataType } from '@/sections/PlanTable/types';
+import type { PlanTableDataType } from '@/sections/PlanTable';
export const PLAN_TABLE_DATA: PlanTableDataType = {
featureColumnLabel: msg`Name`,
diff --git a/packages/twenty-website-new/src/app/[locale]/pricing/salesforce.data.ts b/packages/twenty-website-new/src/app/[locale]/pricing/salesforce.data.ts
index 0191b7c68f..962d346a91 100644
--- a/packages/twenty-website-new/src/app/[locale]/pricing/salesforce.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/pricing/salesforce.data.ts
@@ -1,12 +1,10 @@
import { msg } from '@lingui/core/macro';
-import type { SalesforceDataType } from '@/sections/Salesforce/types';
+import type { SalesforceDataType } from '@/sections/Salesforce';
const SALESFORCE_POPUP_TITLE = msg`Good choice!`;
export const SALESFORCE_DATA: SalesforceDataType = {
- body: {
- text: msg`Some call this enterprise pricing. We prefer a CRM where API access, webhooks, and workflows don't show up as surprise add-ons.`,
- },
+ body: msg`Some call this enterprise pricing. We prefer a CRM where API access, webhooks, and workflows don't show up as surprise add-ons.`,
pricing: {
addons: [
{
diff --git a/packages/twenty-website-new/src/app/[locale]/privacy-policy/page.tsx b/packages/twenty-website-new/src/app/[locale]/privacy-policy/page.tsx
index 95ef27f408..ee7adefa36 100644
--- a/packages/twenty-website-new/src/app/[locale]/privacy-policy/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/privacy-policy/page.tsx
@@ -1,7 +1,7 @@
-import { MENU_DATA } from '@/sections/Menu/data';
+import { MENU_DATA } from '@/sections/Menu';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { LegalDocument } from '@/sections/LegalDocument/components';
+import { LegalDocument } from '@/sections/LegalDocument';
import { buildRouteMetadata } from '@/lib/seo';
import { PrivacyPolicyDocument } from './_components';
diff --git a/packages/twenty-website-new/src/app/[locale]/product/feature.data.ts b/packages/twenty-website-new/src/app/[locale]/product/feature.data.ts
index b75f507737..84e19c6995 100644
--- a/packages/twenty-website-new/src/app/[locale]/product/feature.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/product/feature.data.ts
@@ -1,84 +1,81 @@
import { msg } from '@lingui/core/macro';
-import type { FeatureDataType } from '@/sections/Feature/types';
+import type { ImageType } from '@/design-system/components/Image';
+import type { FeatureTileType } from '@/sections/Feature';
-export const FEATURE_DATA: FeatureDataType = {
- eyebrow: {
- heading: {
- text: msg`Core Features`,
- fontFamily: 'sans',
- },
- },
- mask: { src: '/images/product/feature/mask.webp', alt: '' },
- tiles: [
- {
- icon: 'check',
- image: { src: '/images/product/feature/dashboards.webp', alt: '' },
- heading: { text: msg`Reports & Dashboards`, fontFamily: 'sans' },
- bullets: [
- { text: msg`Build custom dashboards` },
- { text: msg`Aggregate, bar, line, pie, and gauge widgets` },
- { text: msg`Filtered metrics from live CRM data` },
- ],
- },
- {
- icon: 'check',
- image: { src: '/images/product/feature/tasks.webp', alt: '' },
- heading: { text: msg`Tasks & Activities`, fontFamily: 'sans' },
- bullets: [
- { text: msg`Create tasks from records` },
- { text: msg`Assign owners and due dates` },
- { text: msg`Rich notes attached to records` },
- ],
- },
- {
- icon: 'check',
- image: { src: '/images/product/feature/emails.webp', alt: '' },
- heading: { text: msg`Email & Calendar`, fontFamily: 'sans' },
- bullets: [
- { text: msg`Connect Google or Microsoft accounts` },
- { text: msg`Emails and events linked to CRM records` },
- { text: msg`Full communication history in one place` },
- ],
- },
- {
- icon: 'check',
- image: { src: '/images/product/feature/contacts.webp', alt: '' },
- heading: { text: msg`Contacts & Companies`, fontFamily: 'sans' },
- bullets: [
- { text: msg`Custom fields and relationships` },
- { text: msg`Unified timeline (emails, events, tasks, notes, files)` },
- { text: msg`Email/calendar activity on each record` },
- ],
- },
- {
- icon: 'check',
- image: { src: '/images/product/feature/pipeline.webp', alt: '' },
- heading: { text: msg`Pipeline Management`, fontFamily: 'sans' },
- bullets: [
- { text: msg`Custom deal stages for your process` },
- { text: msg`Drag-and-drop deals between stages` },
- { text: msg`Track amount and close date` },
- ],
- },
- {
- icon: 'check',
- image: { src: '/images/product/feature/files.webp', alt: '' },
- heading: { text: msg`Files`, fontFamily: 'sans' },
- bullets: [
- { text: msg`Multi-file upload on records` },
- { text: msg`Rename, download, and delete attachments` },
- { text: msg`In-app preview for supported file types (when enabled)` },
- ],
- },
- {
- icon: 'check',
- image: { src: '/images/product/feature/data.webp', alt: '' },
- heading: { text: msg`Data import`, fontFamily: 'sans' },
- bullets: [
- { text: msg`CSV import flow` },
- { text: msg`Column-to-field mapping (including relations)` },
- { text: msg`CSV export anytime` },
- ],
- },
- ],
+export const FEATURE_MASK: ImageType = {
+ src: '/images/product/feature/mask.webp',
+ alt: '',
};
+
+export const FEATURE_TILES: FeatureTileType[] = [
+ {
+ icon: 'check',
+ image: { src: '/images/product/feature/dashboards.webp', alt: '' },
+ heading: msg`Reports & Dashboards`,
+ bullets: [
+ msg`Build custom dashboards`,
+ msg`Aggregate, bar, line, pie, and gauge widgets`,
+ msg`Filtered metrics from live CRM data`,
+ ],
+ },
+ {
+ icon: 'check',
+ image: { src: '/images/product/feature/tasks.webp', alt: '' },
+ heading: msg`Tasks & Activities`,
+ bullets: [
+ msg`Create tasks from records`,
+ msg`Assign owners and due dates`,
+ msg`Rich notes attached to records`,
+ ],
+ },
+ {
+ icon: 'check',
+ image: { src: '/images/product/feature/emails.webp', alt: '' },
+ heading: msg`Email & Calendar`,
+ bullets: [
+ msg`Connect Google or Microsoft accounts`,
+ msg`Emails and events linked to CRM records`,
+ msg`Full communication history in one place`,
+ ],
+ },
+ {
+ icon: 'check',
+ image: { src: '/images/product/feature/contacts.webp', alt: '' },
+ heading: msg`Contacts & Companies`,
+ bullets: [
+ msg`Custom fields and relationships`,
+ msg`Unified timeline (emails, events, tasks, notes, files)`,
+ msg`Email/calendar activity on each record`,
+ ],
+ },
+ {
+ icon: 'check',
+ image: { src: '/images/product/feature/pipeline.webp', alt: '' },
+ heading: msg`Pipeline Management`,
+ bullets: [
+ msg`Custom deal stages for your process`,
+ msg`Drag-and-drop deals between stages`,
+ msg`Track amount and close date`,
+ ],
+ },
+ {
+ icon: 'check',
+ image: { src: '/images/product/feature/files.webp', alt: '' },
+ heading: msg`Files`,
+ bullets: [
+ msg`Multi-file upload on records`,
+ msg`Rename, download, and delete attachments`,
+ msg`In-app preview for supported file types (when enabled)`,
+ ],
+ },
+ {
+ icon: 'check',
+ image: { src: '/images/product/feature/data.webp', alt: '' },
+ heading: msg`Data import`,
+ bullets: [
+ msg`CSV import flow`,
+ msg`Column-to-field mapping (including relations)`,
+ msg`CSV export anytime`,
+ ],
+ },
+];
diff --git a/packages/twenty-website-new/src/app/[locale]/product/hero.data.ts b/packages/twenty-website-new/src/app/[locale]/product/hero.data.ts
deleted file mode 100644
index 1c2eeedcd1..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/product/hero.data.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { msg } from '@lingui/core/macro';
-
-export const HERO_COPY = {
- body: msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/product/page.tsx b/packages/twenty-website-new/src/app/[locale]/product/page.tsx
index 357dbb2994..e98269834f 100644
--- a/packages/twenty-website-new/src/app/[locale]/product/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/product/page.tsx
@@ -1,14 +1,15 @@
import { msg } from '@lingui/core/macro';
-import { FAQ_DATA } from '@/sections/Faq/data';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
-import { TalkToUsButton } from '@/lib/contact-cal';
+import type { AppLocale } from 'twenty-shared/translations';
+import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
+import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
+import { TalkToUsButton } from '@/sections/ContactCal';
import { DEMO_DATA } from '@/app/[locale]/product/demo.data';
-import { FEATURE_DATA } from '@/app/[locale]/product/feature.data';
-import { HERO_COPY } from '@/app/[locale]/product/hero.data';
-import { STEPPER_DATA } from '@/app/[locale]/product/stepper.data';
-import { TABS_DATA } from '@/app/[locale]/product/tabs.data';
-import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/product/three-cards.data';
+import {
+ FEATURE_MASK,
+ FEATURE_TILES,
+} from '@/app/[locale]/product/feature.data';
+import { TABS } from '@/app/[locale]/product/tabs.data';
+import { ILLUSTRATION_CARDS } from '@/app/[locale]/product/three-cards.data';
import {
Body,
Eyebrow,
@@ -17,24 +18,28 @@ import {
LinkButton,
} from '@/design-system/components';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
+} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { Faq } from '@/sections/Faq/components';
-import { Feature } from '@/sections/Feature/components';
-import { Hero } from '@/sections/Hero/components';
-import { Menu } from '@/sections/Menu/components';
-import { Demo } from '@/sections/Demo/components';
-import { ProductStepper } from '@/sections/ProductStepper/components';
-import { Tabs } from '@/sections/Tabs/components';
-import { ThreeCards } from '@/sections/ThreeCards/components';
-import { TrustedBy } from '@/sections/TrustedBy/components';
+import { Feature } from '@/sections/Feature';
+import { Hero } from '@/sections/Hero';
+import { Menu, MENU_DATA } from '@/sections/Menu';
+import { Demo } from '@/sections/Demo';
+import {
+ ProductStepper,
+ type ProductStepperStepType,
+} from '@/sections/ProductStepper';
+import { Tabs } from '@/sections/Tabs';
+import { ThreeCards } from '@/sections/ThreeCards';
import { theme } from '@/theme';
-import { buildRouteMetadata } from '@/lib/seo';
+import {
+ buildBreadcrumbListJsonLd,
+ buildRouteMetadata,
+ JsonLd,
+} from '@/lib/seo';
export const generateMetadata = buildRouteMetadata('product');
@@ -47,16 +52,55 @@ export default async function ProductPage({ params }: ProductPageProps) {
getRouteI18n(params),
fetchCommunityStats(),
]);
- const renderText = createMessageDescriptorRenderer(i18n);
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
+ const PRODUCT_STEPS: ProductStepperStepType[] = [
+ {
+ icon: 'users',
+ heading: (
+ {i18n._(msg`Data model`)}
+ ),
+ body: msg`Add objects and fields`,
+ image: {
+ src: '/images/product/stepper/step-one.webp',
+ alt: 'Twenty data model: add objects and fields',
+ },
+ },
+ {
+ icon: 'check',
+ heading: (
+ {i18n._(msg`Automation`)}
+ ),
+ body: msg`Create a workflow`,
+ image: {
+ src: '/images/product/stepper/step-two.webp',
+ alt: 'Twenty automation: create a workflow',
+ },
+ },
+ {
+ icon: 'eye',
+ heading: (
+ {i18n._(msg`Layout`)}
+ ),
+ body: msg`Tailor record pages, menus, and views`,
+ image: {
+ src: '/images/product/stepper/step-three.webp',
+ alt: 'Twenty layout: record pages, menus, and views',
+ },
+ },
+ ];
+
return (
<>
- {/*
- * Above-the-fold hero scene. Preload kicks off the GLB fetch in
- * parallel with the JS chunk download, so the model is already in
- * the browser cache by the time Three.js asks for it.
- */}
+
-
+
- {renderText(msg`A CRM for teams`)}
+ {i18n._(msg`A CRM for teams`)}
-
- {renderText(msg`that`)}
- {' '}
-
- {renderText(msg`moves fast`)}
-
+ {i18n._(msg`that`)} {' '}
+ {i18n._(msg`moves fast`)}
-
+
+ {i18n._(
+ msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
+ )}
+
-
-
-
-
-
+
-
+
+
+ {i18n._(msg`AI & Automation`)}
+
+
- {renderText(msg`AI that actually`)}
+ {i18n._(msg`AI that actually`)}
- {renderText(msg`helps you`)}
+ {i18n._(msg`helps you`)}
{' '}
- {renderText(msg`work faster`)}
+ {i18n._(msg`work faster`)}
-
-
+
+ {i18n._(msg`The AI understands your CRM and takes action.`)}
+
+
-
+
-
+
+
+ {i18n._(msg`Core Features`)}
+
+
- {renderText(msg`Everything you need,`)}
+ {i18n._(msg`Everything you need,`)}
{' '}
- {renderText(msg`out of the box`)}
+ {i18n._(msg`out of the box`)}
-
+
-
+
-
+
+
+ {i18n._(msg`Stop settling for trade-offs.`)}
+
+
- {renderText(msg`A modern CRM with`)}
+ {i18n._(msg`A modern CRM with`)}
{' '}
- {renderText(msg`an intuitive interface`)}
+ {i18n._(msg`an intuitive interface`)}
- {THREE_CARDS_ILLUSTRATION_DATA.body && (
-
- )}
-
+
- {renderText(msg`Go the extra mile`)}
+ {i18n._(msg`Go the extra mile`)}
{' '}
-
- {renderText(msg`with no-code`)}
-
+ {i18n._(msg`with no-code`)}
-
+
+
+ {i18n._(msg`Try it live`)}
+
+
- {renderText(msg`A demo worth a`)}
+ {i18n._(msg`A demo worth a`)}
- {renderText(msg`thousand words`)}
+ {i18n._(msg`thousand words`)}
@@ -236,25 +252,25 @@ export default async function ProductPage({ params }: ProductPageProps) {
-
+
+
+ {i18n._(msg`Any Questions?`)}
+
+
- {renderText(msg`Stop fighting custom.`)}
+ {i18n._(msg`Stop fighting custom.`)}
- {renderText(msg`Start building, with Twenty`)}
+ {i18n._(msg`Start building, with Twenty`)}
-
+
>
);
diff --git a/packages/twenty-website-new/src/app/[locale]/product/signoff.data.ts b/packages/twenty-website-new/src/app/[locale]/product/signoff.data.ts
deleted file mode 100644
index 390f828f57..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/product/signoff.data.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { msg } from '@lingui/core/macro';
-
-export const SIGNOFF_COPY = {
- body: msg`Join the teams that chose to own their CRM. Start building with Twenty today.`,
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/product/stepper.data.ts b/packages/twenty-website-new/src/app/[locale]/product/stepper.data.ts
deleted file mode 100644
index cf282a3c2d..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/product/stepper.data.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { msg } from '@lingui/core/macro';
-import type { ProductStepperDataType } from '@/sections/ProductStepper/types';
-
-export const STEPPER_DATA: ProductStepperDataType = {
- eyebrow: {
- heading: {
- text: msg`Customization`,
- fontFamily: 'sans',
- },
- },
- body: {
- text: msg`Need a quick change? Skip the engineering ticket. Customize your workspace in minutes.`,
- },
- steps: [
- {
- icon: 'users',
- heading: {
- text: msg`Data model`,
- fontFamily: 'sans',
- },
- body: {
- text: msg`Add objects and fields`,
- },
- image: {
- src: '/images/product/stepper/step-one.webp',
- alt: 'Twenty data model: add objects and fields',
- },
- },
- {
- icon: 'check',
- heading: {
- text: msg`Automation`,
- fontFamily: 'sans',
- },
- body: {
- text: msg`Create a workflow`,
- },
- image: {
- src: '/images/product/stepper/step-two.webp',
- alt: 'Twenty automation: create a workflow',
- },
- },
- {
- icon: 'eye',
- heading: {
- text: msg`Layout`,
- fontFamily: 'sans',
- },
- body: {
- text: msg`Tailor record pages, menus, and views`,
- },
- image: {
- src: '/images/product/stepper/step-three.webp',
- alt: 'Twenty layout: record pages, menus, and views',
- },
- },
- ],
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/product/tabs.data.ts b/packages/twenty-website-new/src/app/[locale]/product/tabs.data.ts
index 3f56751a5a..f46a5caf4d 100644
--- a/packages/twenty-website-new/src/app/[locale]/product/tabs.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/product/tabs.data.ts
@@ -1,49 +1,38 @@
import { msg } from '@lingui/core/macro';
-import type { TabType } from '@/sections/Tabs/types';
+import type { TabType } from '@/sections/Tabs';
-export const TABS_DATA: {
- eyebrow: { heading: { text: ReturnType; fontFamily: 'sans' } };
- tabs: TabType[];
-} = {
- eyebrow: {
- heading: {
- text: msg`AI & Automation`,
- fontFamily: 'sans',
+export const TABS: TabType[] = [
+ {
+ body: msg`Show me all deals closing this month`,
+ icon: 'search',
+ image: {
+ src: '/images/product/tabs/deals.webp',
+ alt: 'Deals view',
},
},
- tabs: [
- {
- body: msg`Show me all deals closing this month`,
- icon: 'search',
- image: {
- src: '/images/product/tabs/deals.webp',
- alt: 'Deals view',
- },
+ {
+ body: msg`Create follow-up tasks for my top 10 accounts`,
+ icon: 'eye',
+ image: {
+ src: '/images/product/tabs/tasks.webp',
+ alt: 'Tasks view',
},
- {
- body: msg`Create follow-up tasks for my top 10 accounts`,
- icon: 'eye',
- image: {
- src: '/images/product/tabs/tasks.webp',
- alt: 'Tasks view',
- },
+ },
+ {
+ body: msg`Summarize this customer's history`,
+ icon: 'edit',
+ image: {
+ src: '/images/product/tabs/history.webp',
+ alt: 'History view',
},
- {
- body: msg`Summarize this customer's history`,
- icon: 'edit',
- image: {
- src: '/images/product/tabs/history.webp',
- alt: 'History view',
- },
+ },
+ {
+ body: msg`Create a workflow that send an email sequence`,
+ icon: 'check',
+ image: {
+ src: '/images/product/tabs/workflow.webp',
+ alt: 'Workflow view',
},
- {
- body: msg`Create a workflow that send an email sequence`,
- icon: 'check',
- image: {
- src: '/images/product/tabs/workflow.webp',
- alt: 'Workflow view',
- },
- },
- ],
-};
+ },
+];
diff --git a/packages/twenty-website-new/src/app/[locale]/product/three-cards.data.ts b/packages/twenty-website-new/src/app/[locale]/product/three-cards.data.ts
index d27dbf2114..78a9ccd7ba 100644
--- a/packages/twenty-website-new/src/app/[locale]/product/three-cards.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/product/three-cards.data.ts
@@ -1,37 +1,26 @@
import { msg } from '@lingui/core/macro';
-import type { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
+import type { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards';
-export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
- eyebrow: {
- heading: { text: msg`Stop settling for trade-offs.`, fontFamily: 'sans' },
+export const ILLUSTRATION_CARDS: ThreeCardsIllustrationCardType[] = [
+ {
+ heading: msg`Built for speed`,
+ body: msg`Fly through your workspace with shortcuts and short load times.`,
+ benefits: undefined,
+ attribution: undefined,
+ illustration: 'speed',
},
- illustrationCards: [
- {
- heading: { text: msg`Built for speed`, fontFamily: 'sans' },
- body: {
- text: msg`Fly through your workspace with shortcuts and short load times.`,
- },
- benefits: undefined,
- attribution: undefined,
- illustration: 'speed',
- },
- {
- heading: { text: msg`Real-time data`, fontFamily: 'sans' },
- body: {
- text: msg`See updates as they happen. Work with your team and agents seamlessly.`,
- },
- benefits: undefined,
- attribution: undefined,
- illustration: 'eye',
- },
- {
- heading: { text: msg`Stay in Flow`, fontFamily: 'sans' },
- body: {
- text: msg`AI chat, settings, and records in a side panels for fast, single-screen access.`,
- },
- benefits: undefined,
- attribution: undefined,
- illustration: 'singleScreen',
- },
- ],
-};
+ {
+ heading: msg`Real-time data`,
+ body: msg`See updates as they happen. Work with your team and agents seamlessly.`,
+ benefits: undefined,
+ attribution: undefined,
+ illustration: 'eye',
+ },
+ {
+ heading: msg`Stay in Flow`,
+ body: msg`AI chat, settings, and records in a side panels for fast, single-screen access.`,
+ benefits: undefined,
+ attribution: undefined,
+ illustration: 'singleScreen',
+ },
+];
diff --git a/packages/twenty-website-new/src/app/[locale]/releases/hero.data.ts b/packages/twenty-website-new/src/app/[locale]/releases/hero.data.ts
deleted file mode 100644
index 2adca69373..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/releases/hero.data.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { msg } from '@lingui/core/macro';
-
-export const RELEASE_NOTES_HERO_COPY = {
- body: msg`Discover the newest features and improvements in Twenty,\nthe #1 Open Source CRM.`,
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/releases/page.tsx b/packages/twenty-website-new/src/app/[locale]/releases/page.tsx
index 84381bd6d2..5c34947d2b 100644
--- a/packages/twenty-website-new/src/app/[locale]/releases/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/releases/page.tsx
@@ -1,22 +1,19 @@
import { msg } from '@lingui/core/macro';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { RELEASE_NOTES_HERO_COPY } from '@/app/[locale]/releases/hero.data';
import { HeadingPart, LinkButton } from '@/design-system/components';
import { GitHubIcon } from '@/icons';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
import {
getRouteI18n,
type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
+} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { fetchLatestGithubReleaseTag } from '@/lib/releases/fetch-latest-release-tag';
import { getVisibleReleaseNotes } from '@/lib/releases/get-visible-releases';
import { loadLocalReleaseNotes } from '@/lib/releases/load-local-release-notes';
-import { Hero } from '@/sections/Hero/components';
-import { Menu } from '@/sections/Menu/components';
-import { ReleaseNotes } from '@/sections/ReleaseNotes/components';
+import { Hero } from '@/sections/Hero';
+import { Menu, MENU_DATA } from '@/sections/Menu';
+import { ReleaseNotes } from '@/sections/ReleaseNotes';
import { theme } from '@/theme';
import { buildReleaseListJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo';
import { Fragment } from 'react';
@@ -34,7 +31,6 @@ export default async function ReleasesPage({ params }: ReleasesPageProps) {
fetchLatestGithubReleaseTag(),
fetchCommunityStats(),
]);
- const renderText = createMessageDescriptorRenderer(i18n);
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
const visibleNotes =
process.env.NODE_ENV === 'development'
@@ -67,27 +63,22 @@ export default async function ReleasesPage({ params }: ReleasesPageProps) {
-
+
-
- {renderText(msg`Latest`)}
-
+ {i18n._(msg`Latest`)}
-
- {renderText(msg`Releases`)}
-
+ {i18n._(msg`Releases`)}
-
+
+ {i18n._(
+ msg`Discover the newest features and improvements in Twenty,\nthe #1 Open Source CRM.`,
+ )}
+
}
variant="outlined"
/>
diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-four.data.ts b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-four.data.ts
deleted file mode 100644
index b0fd2a50c0..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-four.data.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { msg } from '@lingui/core/macro';
-import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData';
-
-export const EDITORIAL_FOUR: EditorialDataType = {
- eyebrow: {
- heading: {
- fontFamily: 'sans',
- text: msg`What this means`,
- },
- },
- body: [
- {
- text: msg`You don't buy your deployment pipeline off the shelf. You don't rent your data warehouse from a vendor who decides the schema. You build it, you own it, you iterate on it every week. CRM is going the same way. The teams that treat it as infrastructure they own will compound an advantage every quarter.`,
- },
- {
- text: msg`Tuesday your team learns that deals with a technical champion close 3x faster. Wednesday you add the field, wire up the scoring, adjust the workflow. By Thursday your agents are acting on it. That feedback loop is the edge. And it only works if the CRM is yours.`,
- },
- ],
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-one.data.ts b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-one.data.ts
deleted file mode 100644
index b4f2d4b5b2..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-one.data.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { msg } from '@lingui/core/macro';
-import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData';
-
-export const EDITORIAL_ONE: EditorialDataType = {
- eyebrow: {
- heading: {
- fontFamily: 'sans',
- text: msg`The shift`,
- },
- },
- body: [
- {
- text: msg`For twenty years, CRM meant the same thing: a place to log calls, track deals, and pull reports on Friday. The real work happened in people's heads, in Slack threads, in hallway conversations. The CRM kept score. Nobody expected more from it.`,
- },
- {
- text: msg`AI agents are starting to draft outreach, score leads, research accounts, write follow-ups, update deal stages. Every one of these actions reads from and writes to the CRM. The scoreboard became the playbook. The database became the brain.`,
- },
- ],
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-three.data.ts b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-three.data.ts
deleted file mode 100644
index 7d287dd520..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/editorial-three.data.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { msg } from '@lingui/core/macro';
-import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData';
-
-export const EDITORIAL_THREE: EditorialDataType = {
- eyebrow: {
- heading: { fontFamily: 'sans', text: msg`The opportunity` },
- },
- body: [
- {
- text: msg`A year ago, customizing your CRM meant hiring a Salesforce consultant, learning Apex, waiting months. The gap between "I want this" and "it\'s live" was measured in quarters and invoices. So people settled. They bent their process to fit the tool and called it adoption.`,
- },
- {
- text: msg`Now a developer can describe what they want to Claude Code and have a working app in an afternoon. A custom object, a scoring workflow, a new view, an integration. The bottleneck isn't building anymore. It's whether your platform lets you.`,
- },
- ],
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/hero.data.ts b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/hero.data.ts
deleted file mode 100644
index 4ca1f72b78..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/hero.data.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { msg } from '@lingui/core/macro';
-
-export const HERO_COPY = {
- body: msg`CRM was a database you filled on Fridays. AI turned it into the system that runs your go-to-market. To differentiate, you have to build what your competitors can't buy.`,
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/marquee.data.ts b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/marquee.data.ts
deleted file mode 100644
index 4f83779490..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/marquee.data.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { msg } from '@lingui/core/macro';
-import type { MarqueeDataType } from '@/sections/Marquee/types';
-
-export const MARQUEE_DATA: MarqueeDataType = {
- heading: [
- { fontFamily: 'serif', text: msg`Same CRM` },
- { fontFamily: 'sans', text: msg`Same output` },
- { fontFamily: 'serif', text: msg`Same results` },
- ],
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/page.tsx b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/page.tsx
deleted file mode 100644
index a725ab11c4..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/page.tsx
+++ /dev/null
@@ -1,259 +0,0 @@
-import { msg } from '@lingui/core/macro';
-import { MENU_DATA } from '@/sections/Menu/data';
-import { EDITORIAL_FOUR } from '@/app/[locale]/resources/why-twenty/editorial-four.data';
-import { EDITORIAL_ONE } from '@/app/[locale]/resources/why-twenty/editorial-one.data';
-import { EDITORIAL_THREE } from '@/app/[locale]/resources/why-twenty/editorial-three.data';
-import { HERO_COPY } from '@/app/[locale]/resources/why-twenty/hero.data';
-import { MARQUEE_DATA } from '@/app/[locale]/resources/why-twenty/marquee.data';
-import { SIGNOFF_COPY } from '@/app/[locale]/resources/why-twenty/signoff.data';
-import { HeadingPart, LinkButton } from '@/design-system/components';
-import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
-import {
- getRouteI18n,
- type LocaleRouteParams,
-} from '@/lib/i18n/get-route-i18n';
-import { Pages } from '@/lib/pages';
-import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { Editorial } from '@/sections/Editorial/components';
-import { Hero } from '@/sections/Hero/components';
-import { Marquee } from '@/sections/Marquee/components';
-import { Menu } from '@/sections/Menu/components';
-import { Signoff } from '@/sections/Signoff/components';
-import { theme } from '@/theme';
-import { buildRouteMetadata } from '@/lib/seo';
-import { css } from '@linaria/core';
-
-const editorialOneIntroClass = css`
- margin-bottom: ${theme.spacing(4)};
- --editorial-heading-max-width: 760px;
- --editorial-intro-max-width: 760px;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- margin-bottom: ${theme.spacing(8)};
- }
-`;
-
-const editorialRightIntroClass = css`
- margin-bottom: ${theme.spacing(4)};
- --editorial-heading-max-width: 760px;
- --editorial-intro-max-width: 760px;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- align-items: flex-end;
- margin-bottom: ${theme.spacing(8)};
- margin-left: auto;
- margin-right: 0;
- text-align: right;
- width: auto;
- }
-`;
-
-const crosshairLineColor = theme.colors.secondary.border[10];
-
-const sectionCrosshairLeft = {
- crossX: '120px',
- crossY: '0px',
- lineColor: crosshairLineColor,
-};
-
-const sectionCrosshairRight = {
- crossX: 'calc(100% - 120px)',
- crossY: '0px',
- lineColor: crosshairLineColor,
-};
-
-export const generateMetadata = buildRouteMetadata('whyTwenty');
-
-type WhyTwentyPageProps = {
- params: Promise;
-};
-
-export default async function WhyTwentyPage({ params }: WhyTwentyPageProps) {
- const [i18n, stats] = await Promise.all([
- getRouteI18n(params),
- fetchCommunityStats(),
- ]);
- const renderText = createMessageDescriptorRenderer(i18n);
- const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
-
- return (
- <>
- {/*
- * Above-the-fold hero scene. Preload kicks off the GLB fetch in
- * parallel with the JS chunk download, so the model is already in
- * the browser cache by the time Three.js asks for it.
- */}
-
-
-
-
-
-
-
-
-
-
-
- {renderText(msg`The future of CRM is built,`)}
- {' '}
-
- {renderText(msg`not bought.`)}
-
-
-
-
-
-
-
-
-
-
-
- {renderText(msg`CRM was a ledger.`)}
- {' '}
-
- {renderText(msg`AI turned it into an operating system.`)}
-
-
-
-
-
-
- {/*
-
-
-
- */}
-
-
-
-
-
-
- {renderText(msg`Differentiation now`)}
- {' '}
-
- {renderText(msg`lives in the code you own.`)}
-
-
-
-
-
-
-
-
-
-
-
- {renderText(msg`Build it in an afternoon.`)}
- {' '}
-
- {renderText(msg`AI made the gap that small.`)}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {renderText(msg`Build a CRM your competitors`)}
- {' '}
-
- {renderText(msg`can't buy.`)}
-
-
-
-
-
-
-
- >
- );
-}
diff --git a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/signoff.data.ts b/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/signoff.data.ts
deleted file mode 100644
index 7f8efcb4c9..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/resources/why-twenty/signoff.data.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { msg } from '@lingui/core/macro';
-
-export const SIGNOFF_COPY = {
- body: msg`Open-source, AI-ready, and yours to shape.`,
-};
diff --git a/packages/twenty-website-new/src/app/[locale]/terms/page.tsx b/packages/twenty-website-new/src/app/[locale]/terms/page.tsx
index c8dd6938a9..84bec87683 100644
--- a/packages/twenty-website-new/src/app/[locale]/terms/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/terms/page.tsx
@@ -1,7 +1,7 @@
-import { MENU_DATA } from '@/sections/Menu/data';
+import { MENU_DATA } from '@/sections/Menu';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
-import { LegalDocument } from '@/sections/LegalDocument/components';
+import { LegalDocument } from '@/sections/LegalDocument';
import { buildRouteMetadata } from '@/lib/seo';
import { TermsDocument } from './_components';
diff --git a/packages/twenty-website-new/src/app/[locale]/why-twenty/page.tsx b/packages/twenty-website-new/src/app/[locale]/why-twenty/page.tsx
new file mode 100644
index 0000000000..5f7b1d4eac
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/why-twenty/page.tsx
@@ -0,0 +1,214 @@
+import { msg } from '@lingui/core/macro';
+import { HeadingPart, LinkButton } from '@/design-system/components';
+import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
+import {
+ getRouteI18n,
+ type LocaleRouteParams,
+} from '@/lib/i18n/utils/get-route-i18n';
+import { Pages } from '@/lib/pages';
+import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
+import { Editorial } from '@/sections/Editorial';
+import { Hero } from '@/sections/Hero';
+import { Marquee } from '@/sections/Marquee';
+import { Menu, MENU_DATA } from '@/sections/Menu';
+import { Signoff } from '@/sections/Signoff';
+import { theme } from '@/theme';
+import { buildRouteMetadata } from '@/lib/seo';
+import { css } from '@linaria/core';
+
+const editorialOneIntroClass = css`
+ margin-bottom: ${theme.spacing(4)};
+ --editorial-heading-max-width: 760px;
+ --editorial-intro-max-width: 760px;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ margin-bottom: ${theme.spacing(8)};
+ }
+`;
+
+const editorialRightIntroClass = css`
+ margin-bottom: ${theme.spacing(4)};
+ --editorial-heading-max-width: 760px;
+ --editorial-intro-max-width: 760px;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ align-items: flex-end;
+ margin-bottom: ${theme.spacing(8)};
+ margin-left: auto;
+ margin-right: 0;
+ text-align: right;
+ width: auto;
+ }
+`;
+
+const crosshairLineColor = theme.colors.secondary.border[10];
+
+const sectionCrosshairLeft = {
+ crossX: '120px',
+ crossY: '0px',
+ lineColor: crosshairLineColor,
+};
+
+const sectionCrosshairRight = {
+ crossX: 'calc(100% - 120px)',
+ crossY: '0px',
+ lineColor: crosshairLineColor,
+};
+
+export const generateMetadata = buildRouteMetadata('whyTwenty');
+
+type WhyTwentyPageProps = {
+ params: Promise;
+};
+
+export default async function WhyTwentyPage({ params }: WhyTwentyPageProps) {
+ const [i18n, stats] = await Promise.all([
+ getRouteI18n(params),
+ fetchCommunityStats(),
+ ]);
+ const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
+
+ return (
+ <>
+ {/*
+ * Above-the-fold hero scene. Preload kicks off the GLB fetch in
+ * parallel with the JS chunk download, so the model is already in
+ * the browser cache by the time Three.js asks for it.
+ */}
+
+
+
+
+
+
+
+
+
+
+
+ {i18n._(msg`The future of CRM is built,`)}
+ {' '}
+
+ {i18n._(msg`not bought.`)}
+
+
+
+ {i18n._(
+ msg`CRM was a database you filled on Fridays. AI turned it into the system that runs your go-to-market. To differentiate, you have to build what your competitors can't buy.`,
+ )}
+
+
+
+
+
+
+
+ {i18n._(msg`The shift`)}
+
+
+
+ {i18n._(msg`CRM was a ledger.`)}
+ {' '}
+
+ {i18n._(msg`AI turned it into an operating system.`)}
+
+
+
+
+ {i18n._(
+ msg`For twenty years, CRM meant the same thing: a place to log calls, track deals, and pull reports on Friday. The real work happened in people's heads, in Slack threads, in hallway conversations. The CRM kept score. Nobody expected more from it.`,
+ )}
+ {i18n._(
+ msg`AI agents are starting to draft outreach, score leads, research accounts, write follow-ups, update deal stages. Every one of these actions reads from and writes to the CRM. The scoreboard became the playbook. The database became the brain.`,
+ )}
+
+
+
+
+
+
+ {i18n._(msg`What this means`)}
+
+
+
+ {i18n._(msg`Differentiation now`)}
+ {' '}
+
+ {i18n._(msg`lives in the code you own.`)}
+
+
+
+
+ {i18n._(
+ msg`You don't buy your deployment pipeline off the shelf. You don't rent your data warehouse from a vendor who decides the schema. You build it, you own it, you iterate on it every week. CRM is going the same way. The teams that treat it as infrastructure they own will compound an advantage every quarter.`,
+ )}
+ {i18n._(
+ msg`Tuesday your team learns that deals with a technical champion close 3x faster. Wednesday you add the field, wire up the scoring, adjust the workflow. By Thursday your agents are acting on it. That feedback loop is the edge. And it only works if the CRM is yours.`,
+ )}
+
+
+
+
+
+
+ {i18n._(msg`The opportunity`)}
+
+
+
+ {i18n._(msg`Build it in an afternoon.`)}
+ {' '}
+
+ {i18n._(msg`AI made the gap that small.`)}
+
+
+
+
+ {i18n._(
+ msg`A year ago, customizing your CRM meant hiring a Salesforce consultant, learning Apex, waiting months. The gap between "I want this" and "it's live" was measured in quarters and invoices. So people settled. They bent their process to fit the tool and called it adoption.`,
+ )}
+ {i18n._(
+ msg`Now a developer can describe what they want to Claude Code and have a working app in an afternoon. A custom object, a scoring workflow, a new view, an integration. The bottleneck isn't building anymore. It's whether your platform lets you.`,
+ )}
+
+
+
+
+
+
+
+
+ {i18n._(msg`Build a CRM your competitors`)}
+ {' '}
+ {i18n._(msg`can't buy.`)}
+
+
+ {i18n._(msg`Open-source, AI-ready, and yours to shape.`)}
+
+
+
+
+
+ >
+ );
+}
diff --git a/packages/twenty-website-new/src/app/_components/ScrollToTopOnRouteChange.tsx b/packages/twenty-website-new/src/app/_components/ScrollToTopOnRouteChange.tsx
index 4753f5f836..84fe95cd90 100644
--- a/packages/twenty-website-new/src/app/_components/ScrollToTopOnRouteChange.tsx
+++ b/packages/twenty-website-new/src/app/_components/ScrollToTopOnRouteChange.tsx
@@ -1,13 +1,13 @@
'use client';
import { usePathname } from 'next/navigation';
-import { useEffect, useRef } from 'react';
+import { useLayoutEffect, useRef } from 'react';
export function ScrollToTopOnRouteChange() {
const pathname = usePathname();
const isInitialRenderRef = useRef(true);
- useEffect(() => {
+ useLayoutEffect(() => {
if (isInitialRenderRef.current) {
isInitialRenderRef.current = false;
return;
diff --git a/packages/twenty-website-new/src/app/api/partner-application/route.ts b/packages/twenty-website-new/src/app/api/partner-application/route.ts
index 21f801147d..64da364709 100644
--- a/packages/twenty-website-new/src/app/api/partner-application/route.ts
+++ b/packages/twenty-website-new/src/app/api/partner-application/route.ts
@@ -4,7 +4,7 @@ import {
getClientIpKey,
readJsonBody,
} from '@/lib/api';
-import { splitFullName } from '@/lib/partner-application';
+import { splitFullName } from '@/sections/PartnerApplication';
import { NextResponse } from 'next/server';
import { z } from 'zod';
diff --git a/packages/twenty-website-new/src/app/articles/feed.xml/route.ts b/packages/twenty-website-new/src/app/articles/feed.xml/route.ts
new file mode 100644
index 0000000000..7569401a36
--- /dev/null
+++ b/packages/twenty-website-new/src/app/articles/feed.xml/route.ts
@@ -0,0 +1,48 @@
+import { getPublishedArticles } from '@/lib/articles';
+import { getSiteUrl } from '@/lib/seo/site-url';
+
+export function GET() {
+ const siteUrl = getSiteUrl();
+ const articles = getPublishedArticles();
+
+ const items = articles
+ .map(
+ (article) => ` -
+
${escapeXml(article.title)}
+ ${siteUrl}/articles/${article.slug}
+ ${siteUrl}/articles/${article.slug}
+ ${escapeXml(article.description)}
+ ${new Date(article.date).toUTCString()}
+ ${escapeXml(article.author)}
+ `,
+ )
+ .join('\n');
+
+ const feed = `
+
+
+ Twenty Articles
+ ${siteUrl}/articles
+ Articles and insights from the Twenty team
+ en
+
+${items}
+
+ `;
+
+ return new Response(feed, {
+ headers: {
+ 'Content-Type': 'application/xml; charset=utf-8',
+ 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
+ },
+ });
+}
+
+function escapeXml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
diff --git a/packages/twenty-website-new/src/app/sitemap.ts b/packages/twenty-website-new/src/app/sitemap.ts
index 77ccac9ca1..a21f0dc095 100644
--- a/packages/twenty-website-new/src/app/sitemap.ts
+++ b/packages/twenty-website-new/src/app/sitemap.ts
@@ -9,6 +9,7 @@ import {
} from '@/lib/website-routing';
const SITE_URL = getSiteUrl();
+const BUILD_DATE = new Date().toISOString();
const buildLocalizedUrl = (locale: AppLocale, path: string): string => {
const prefix =
@@ -40,6 +41,7 @@ const localize = (
): MetadataRoute.Sitemap =>
locales.map((locale) => ({
url: buildLocalizedUrl(locale, path),
+ lastModified: BUILD_DATE,
changeFrequency,
priority,
alternates: { languages: buildLanguageAlternates(path, locales) },
diff --git a/packages/twenty-website-new/src/content/site/asset-paths.ts b/packages/twenty-website-new/src/content/site/asset-paths.ts
index ab03b3eb0f..eec689a417 100644
--- a/packages/twenty-website-new/src/content/site/asset-paths.ts
+++ b/packages/twenty-website-new/src/content/site/asset-paths.ts
@@ -1,81 +1,81 @@
export const SHARED_COMPANY_LOGO_URLS = {
- a16z: '/images/shared/companies/logos/a16z.png',
- accel: '/images/shared/companies/logos/accel.png',
- airbnb: '/images/shared/companies/logos/airbnb.png',
- airtable: '/images/shared/companies/logos/airtable.png',
- anthropic: '/images/shared/companies/logos/anthropic.png',
- apple: '/images/shared/companies/logos/apple.png',
- apple1977: '/images/shared/companies/logos/apple-1977.png',
- calendar: '/images/shared/companies/logos/calendar.png',
- claude: '/images/shared/companies/logos/claude.png',
- cursor: '/images/shared/companies/logos/cursor.png',
- docusign: '/images/shared/companies/logos/docusign.png',
- figma: '/images/shared/companies/logos/figma.png',
- foundersFund: '/images/shared/companies/logos/founders-fund.png',
- github: '/images/shared/companies/logos/github.png',
- gmail: '/images/shared/companies/logos/gmail.png',
- google: '/images/shared/companies/logos/google.png',
- hubspot: '/images/shared/companies/logos/hubspot.png',
- kleinerPerkins: '/images/shared/companies/logos/kleiner-perkins.png',
- lemlist: '/images/shared/companies/logos/lemlist.png',
+ a16z: '/images/shared/companies/logos/a16z.webp',
+ accel: '/images/shared/companies/logos/accel.webp',
+ airbnb: '/images/shared/companies/logos/airbnb.webp',
+ airtable: '/images/shared/companies/logos/airtable.webp',
+ anthropic: '/images/shared/companies/logos/anthropic.webp',
+ apple: '/images/shared/companies/logos/apple.webp',
+ apple1977: '/images/shared/companies/logos/apple-1977.webp',
+ calendar: '/images/shared/companies/logos/calendar.webp',
+ claude: '/images/shared/companies/logos/claude.webp',
+ cursor: '/images/shared/companies/logos/cursor.webp',
+ docusign: '/images/shared/companies/logos/docusign.webp',
+ figma: '/images/shared/companies/logos/figma.webp',
+ foundersFund: '/images/shared/companies/logos/founders-fund.webp',
+ github: '/images/shared/companies/logos/github.webp',
+ gmail: '/images/shared/companies/logos/gmail.webp',
+ google: '/images/shared/companies/logos/google.webp',
+ hubspot: '/images/shared/companies/logos/hubspot.webp',
+ kleinerPerkins: '/images/shared/companies/logos/kleiner-perkins.webp',
+ lemlist: '/images/shared/companies/logos/lemlist.webp',
linear: '/images/shared/companies/logos/linear.svg',
- linkedin: '/images/shared/companies/logos/linkedin.png',
- mailchimp: '/images/shared/companies/logos/mailchimp.png',
- meet: '/images/shared/companies/logos/meet.png',
- metabase: '/images/shared/companies/logos/metabase.png',
- microsoft: '/images/shared/companies/logos/microsoft.png',
- notion: '/images/shared/companies/logos/notion.png',
- okta: '/images/shared/companies/logos/okta.png',
- openai: '/images/shared/companies/logos/openai.png',
- outlook: '/images/shared/companies/logos/outlook.png',
- outreach: '/images/shared/companies/logos/outreach.png',
- postgresql: '/images/shared/companies/logos/postgresql.png',
- qonto: '/images/shared/companies/logos/qonto.png',
- salesforce: '/images/shared/companies/logos/salesforce.png',
- segment: '/images/shared/companies/logos/segment.png',
- sequoia: '/images/shared/companies/logos/sequoia.png',
- slack: '/images/shared/companies/logos/slack.png',
- stripe: '/images/shared/companies/logos/stripe.png',
- tally: '/images/shared/companies/logos/tally.png',
- twenty: '/images/shared/companies/logos/twenty.png',
- whatsapp: '/images/shared/companies/logos/whatsapp.png',
- zapier: '/images/shared/companies/logos/zapier.png',
+ linkedin: '/images/shared/companies/logos/linkedin.webp',
+ mailchimp: '/images/shared/companies/logos/mailchimp.webp',
+ meet: '/images/shared/companies/logos/meet.webp',
+ metabase: '/images/shared/companies/logos/metabase.webp',
+ microsoft: '/images/shared/companies/logos/microsoft.webp',
+ notion: '/images/shared/companies/logos/notion.webp',
+ okta: '/images/shared/companies/logos/okta.webp',
+ openai: '/images/shared/companies/logos/openai.webp',
+ outlook: '/images/shared/companies/logos/outlook.webp',
+ outreach: '/images/shared/companies/logos/outreach.webp',
+ postgresql: '/images/shared/companies/logos/postgresql.webp',
+ qonto: '/images/shared/companies/logos/qonto.webp',
+ salesforce: '/images/shared/companies/logos/salesforce.webp',
+ segment: '/images/shared/companies/logos/segment.webp',
+ sequoia: '/images/shared/companies/logos/sequoia.webp',
+ slack: '/images/shared/companies/logos/slack.webp',
+ stripe: '/images/shared/companies/logos/stripe.webp',
+ tally: '/images/shared/companies/logos/tally.webp',
+ twenty: '/images/shared/companies/logos/twenty.webp',
+ whatsapp: '/images/shared/companies/logos/whatsapp.webp',
+ zapier: '/images/shared/companies/logos/zapier.webp',
} as const;
export const SHARED_PEOPLE_AVATAR_URLS = {
- alexandreProt: '/images/shared/people/avatars/alexandre-prot.jpg',
- anonymousAnna: '/images/shared/people/avatars/anonymous-anna.jpg',
- anonymousFabrice: '/images/shared/people/avatars/anonymous-fabrice.jpg',
- anonymousFelix: '/images/shared/people/avatars/anonymous-felix.jpg',
- anonymousIndira: '/images/shared/people/avatars/anonymous-indira.jpg',
- anonymousLaura: '/images/shared/people/avatars/anonymous-laura.jpg',
- anonymousMike: '/images/shared/people/avatars/anonymous-mike.jpg',
- anonymousThomas: '/images/shared/people/avatars/anonymous-thomas.jpg',
- benChestnut: '/images/shared/people/avatars/ben-chestnut.jpg',
- brianChesky: '/images/shared/people/avatars/brian-chesky.jpg',
- chrisWanstrath: '/images/shared/people/avatars/chris-wanstrath.jpg',
- craigFederighi: '/images/shared/people/avatars/craig-federighi.jpg',
- darioAmodei: '/images/shared/people/avatars/dario-amodei.jpg',
- dylanField: '/images/shared/people/avatars/dylan-field.jpg',
- eddyCue: '/images/shared/people/avatars/eddy-cue.jpg',
- ivanZhao: '/images/shared/people/avatars/ivan-zhao.jpg',
- jeffWilliams: '/images/shared/people/avatars/jeff-williams.jpg',
- joeGebbia: '/images/shared/people/avatars/joe-gebbia.jpg',
- katherineAdams: '/images/shared/people/avatars/katherine-adams.jpg',
- patrickCollison: '/images/shared/people/avatars/patrick-collison.jpg',
- pingLi: '/images/shared/people/avatars/ping-li.jpg',
- peterReinhardt: '/images/shared/people/avatars/peter-reinhardt.jpg',
- peterThiel: '/images/shared/people/avatars/peter-thiel.jpg',
- philSchiller: '/images/shared/people/avatars/phil-schiller.jpg',
- rayDamm: '/images/shared/people/avatars/ray-damm.jpg',
- reidHoffman: '/images/shared/people/avatars/reid-hoffman.jpg',
- roelofBotha: '/images/shared/people/avatars/roelof-botha.jpg',
- ryanRoslansky: '/images/shared/people/avatars/ryan-roslansky.jpg',
- steveAnavi: '/images/shared/people/avatars/steve-anavi.jpg',
- stewartButterfield: '/images/shared/people/avatars/stewart-butterfield.jpg',
- sundarPichai: '/images/shared/people/avatars/sundar-pichai.jpg',
- thomasDohmke: '/images/shared/people/avatars/thomas-dohmke.jpg',
- timCook: '/images/shared/people/avatars/tim-cook.jpg',
+ alexandreProt: '/images/shared/people/avatars/alexandre-prot.webp',
+ anonymousAnna: '/images/shared/people/avatars/anonymous-anna.webp',
+ anonymousFabrice: '/images/shared/people/avatars/anonymous-fabrice.webp',
+ anonymousFelix: '/images/shared/people/avatars/anonymous-felix.webp',
+ anonymousIndira: '/images/shared/people/avatars/anonymous-indira.webp',
+ anonymousLaura: '/images/shared/people/avatars/anonymous-laura.webp',
+ anonymousMike: '/images/shared/people/avatars/anonymous-mike.webp',
+ anonymousThomas: '/images/shared/people/avatars/anonymous-thomas.webp',
+ benChestnut: '/images/shared/people/avatars/ben-chestnut.webp',
+ brianChesky: '/images/shared/people/avatars/brian-chesky.webp',
+ chrisWanstrath: '/images/shared/people/avatars/chris-wanstrath.webp',
+ craigFederighi: '/images/shared/people/avatars/craig-federighi.webp',
+ darioAmodei: '/images/shared/people/avatars/dario-amodei.webp',
+ dylanField: '/images/shared/people/avatars/dylan-field.webp',
+ eddyCue: '/images/shared/people/avatars/eddy-cue.webp',
+ ivanZhao: '/images/shared/people/avatars/ivan-zhao.webp',
+ jeffWilliams: '/images/shared/people/avatars/jeff-williams.webp',
+ joeGebbia: '/images/shared/people/avatars/joe-gebbia.webp',
+ katherineAdams: '/images/shared/people/avatars/katherine-adams.webp',
+ patrickCollison: '/images/shared/people/avatars/patrick-collison.webp',
+ pingLi: '/images/shared/people/avatars/ping-li.webp',
+ peterReinhardt: '/images/shared/people/avatars/peter-reinhardt.webp',
+ peterThiel: '/images/shared/people/avatars/peter-thiel.webp',
+ philSchiller: '/images/shared/people/avatars/phil-schiller.webp',
+ rayDamm: '/images/shared/people/avatars/ray-damm.webp',
+ reidHoffman: '/images/shared/people/avatars/reid-hoffman.webp',
+ roelofBotha: '/images/shared/people/avatars/roelof-botha.webp',
+ ryanRoslansky: '/images/shared/people/avatars/ryan-roslansky.webp',
+ steveAnavi: '/images/shared/people/avatars/steve-anavi.webp',
+ stewartButterfield: '/images/shared/people/avatars/stewart-butterfield.webp',
+ sundarPichai: '/images/shared/people/avatars/sundar-pichai.webp',
+ thomasDohmke: '/images/shared/people/avatars/thomas-dohmke.webp',
+ timCook: '/images/shared/people/avatars/tim-cook.webp',
} as const;
export const SHARED_COMPANY_LOGO_URLS_BY_DOMAIN = {
diff --git a/packages/twenty-website-new/src/design-system/components/Body.tsx b/packages/twenty-website-new/src/design-system/components/Body.tsx
index 110fae5208..b184fb7fd9 100644
--- a/packages/twenty-website-new/src/design-system/components/Body.tsx
+++ b/packages/twenty-website-new/src/design-system/components/Body.tsx
@@ -2,10 +2,6 @@ import { theme } from '@/theme';
import { css } from '@linaria/core';
import type { ReactNode } from 'react';
-export type BodyType = {
- text: TText;
-};
-
const bodyClassName = css`
color: inherit;
margin: 0;
@@ -42,7 +38,7 @@ const bodyClassName = css`
}
&[data-size='sm'] {
- color: var(--body-sm-color, ${theme.colors.primary.text[60]});
+ color: var(--color-text-muted, ${theme.colors.primary.text[60]});
font-size: ${theme.font.size(4)};
line-height: 1.55;
}
@@ -70,7 +66,7 @@ const bodyClassName = css`
}
&[data-variant='body-paragraph'] {
- color: var(--body-paragraph-color, ${theme.colors.primary.text[80]});
+ color: var(--color-text-muted, ${theme.colors.primary.text[80]});
line-height: 1.55;
}
`;
@@ -81,33 +77,26 @@ export type BodyWeight = 'light' | 'regular' | 'medium';
export type BodySize = 'md' | 'sm' | 'xs';
export type BodyVariant = 'default' | 'body-paragraph';
-type BodyTextRenderer = [TText] extends [ReactNode]
- ? { renderText?: (text: TText) => ReactNode }
- : { renderText: (text: TText) => ReactNode };
-
-export type BodyProps = {
+export type BodyProps = {
as?: BodyAs;
- body: BodyType;
+ children: ReactNode;
family?: BodyFamily;
weight?: BodyWeight;
size?: BodySize;
variant?: BodyVariant;
className?: string;
-} & BodyTextRenderer;
+};
-export function Body({
+export function Body({
as: Tag = 'p',
- body,
+ children,
family = 'sans',
- renderText,
weight = 'regular',
size = 'md',
variant = 'default',
className,
-}: BodyProps) {
+}: BodyProps) {
const rootClassName = [bodyClassName, className].filter(Boolean).join(' ');
- const content =
- renderText === undefined ? (body.text as ReactNode) : renderText(body.text);
return (
({
data-size={size}
data-variant={variant}
>
- {content}
+ {children}
);
}
diff --git a/packages/twenty-website-new/src/design-system/components/Button/LinkButton.tsx b/packages/twenty-website-new/src/design-system/components/Button/LinkButton.tsx
index b63e33d8fe..2f59c99d51 100644
--- a/packages/twenty-website-new/src/design-system/components/Button/LinkButton.tsx
+++ b/packages/twenty-website-new/src/design-system/components/Button/LinkButton.tsx
@@ -1,4 +1,4 @@
-import type { LinkButtonType } from '@/design-system/components/Button/types/LinkButtonType';
+import type { LinkButtonType } from '@/design-system/components/Button/types/link-button-type';
import { styled } from '@linaria/react';
import {
BaseButton,
diff --git a/packages/twenty-website-new/src/design-system/components/Button/SubmitButton.tsx b/packages/twenty-website-new/src/design-system/components/Button/SubmitButton.tsx
deleted file mode 100644
index 829094fa90..0000000000
--- a/packages/twenty-website-new/src/design-system/components/Button/SubmitButton.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { styled } from '@linaria/react';
-import {
- BaseButton,
- type BaseButtonProps,
- buttonBaseStyles,
-} from './BaseButton';
-import type { SubmitButtonType } from './types/SubmitButtonType';
-
-const StyledSubmitButton = styled.button`
- ${buttonBaseStyles}
-`;
-
-export type SubmitButtonProps = Omit &
- SubmitButtonType & { onClick?: () => void };
-
-export function SubmitButton({
- color,
- label,
- leadingIcon,
- onClick,
- size = 'regular',
- variant,
-}: SubmitButtonProps) {
- return (
-
-
-
- );
-}
diff --git a/packages/twenty-website-new/src/design-system/components/Button/types/SubmitButtonType.ts b/packages/twenty-website-new/src/design-system/components/Button/types/SubmitButtonType.ts
deleted file mode 100644
index 610819aeaf..0000000000
--- a/packages/twenty-website-new/src/design-system/components/Button/types/SubmitButtonType.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import type { ReactNode } from 'react';
-
-export type SubmitButtonType = { label: ReactNode };
diff --git a/packages/twenty-website-new/src/design-system/components/Button/types/LinkButtonType.ts b/packages/twenty-website-new/src/design-system/components/Button/types/link-button-type.ts
similarity index 100%
rename from packages/twenty-website-new/src/design-system/components/Button/types/LinkButtonType.ts
rename to packages/twenty-website-new/src/design-system/components/Button/types/link-button-type.ts
diff --git a/packages/twenty-website-new/src/design-system/components/Container.tsx b/packages/twenty-website-new/src/design-system/components/Container.tsx
index 2de65bbc6a..c94b3f8023 100644
--- a/packages/twenty-website-new/src/design-system/components/Container.tsx
+++ b/packages/twenty-website-new/src/design-system/components/Container.tsx
@@ -11,12 +11,18 @@ const ContainerRoot = styled.div`
export type ContainerProps = {
children: ReactNode;
className?: string;
+ 'data-scheme'?: string;
style?: CSSProperties;
};
-export function Container({ children, className, style }: ContainerProps) {
+export function Container({
+ children,
+ className,
+ 'data-scheme': dataScheme,
+ style,
+}: ContainerProps) {
return (
-
+
{children}
);
diff --git a/packages/twenty-website-new/src/design-system/components/Eyebrow.tsx b/packages/twenty-website-new/src/design-system/components/Eyebrow.tsx
index a667237841..31a079379a 100644
--- a/packages/twenty-website-new/src/design-system/components/Eyebrow.tsx
+++ b/packages/twenty-website-new/src/design-system/components/Eyebrow.tsx
@@ -1,12 +1,10 @@
-import { Heading, type HeadingType } from '@/design-system/components/Heading';
+import { Heading } from '@/design-system/components/Heading';
import { RectangleFillIcon } from '@/icons';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
-export type EyebrowType = { heading: HeadingType };
-
const EyebrowRow = styled.div`
align-items: center;
display: flex;
@@ -38,31 +36,22 @@ const eyebrowLabelClassName = css`
}
`;
-type EyebrowTextRenderer = [TText] extends [ReactNode]
- ? { renderText?: (text: TText) => ReactNode }
- : { renderText: (text: TText) => ReactNode };
-
-type EyebrowProps = {
- heading: HeadingType;
- colorScheme: 'primary' | 'secondary';
+export type EyebrowProps = {
+ children: ReactNode;
+ colorScheme?: 'primary' | 'secondary';
markerHeight?: number;
markerWidth?: number;
-} & EyebrowTextRenderer;
+};
-export function Eyebrow({
- heading,
- colorScheme,
+export function Eyebrow({
+ children,
+ colorScheme = 'primary',
markerHeight,
markerWidth,
- renderText,
-}: EyebrowProps) {
+}: EyebrowProps) {
const colorClassName =
colorScheme === 'primary' ? eyebrowColorPrimary : eyebrowColorSecondary;
const headingClassName = [eyebrowLabelClassName, colorClassName].join(' ');
- const headingSegment = {
- fontFamily: heading.fontFamily,
- text: heading.text,
- };
return (
@@ -74,24 +63,9 @@ export function Eyebrow({
width={markerWidth}
/>
- {renderText === undefined ? (
-
- ) : (
-
- as="h3"
- className={headingClassName}
- renderText={renderText}
- segments={headingSegment}
- size="xs"
- weight="medium"
- />
- )}
+
+ {children}
+
);
}
diff --git a/packages/twenty-website-new/src/design-system/components/Heading.tsx b/packages/twenty-website-new/src/design-system/components/Heading.tsx
index 8bf91aa9de..1eb0d84eb4 100644
--- a/packages/twenty-website-new/src/design-system/components/Heading.tsx
+++ b/packages/twenty-website-new/src/design-system/components/Heading.tsx
@@ -1,15 +1,7 @@
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
-import { Fragment, type ReactNode } from 'react';
-
-export type HeadingType = {
- fontFamily: 'sans' | 'serif' | 'mono';
- text: TText;
- fontWeight?: 'light' | 'regular' | 'medium';
- newLine?: boolean;
- lineBreakBefore?: boolean;
-};
+import type { ReactNode } from 'react';
const headingRootClassName = css`
margin: 0;
@@ -108,6 +100,11 @@ const StyledSpan = styled.span`
}
`;
+export type HeadingAs = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
+export type HeadingFamily = 'sans' | 'serif' | 'mono';
+export type HeadingWeight = 'light' | 'regular' | 'medium';
+export type HeadingSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs';
+
type HeadingPartProps = {
children: ReactNode;
fontFamily: HeadingFamily;
@@ -126,73 +123,28 @@ export function HeadingPart({
);
}
-export type HeadingAs = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
-export type HeadingFamily = 'sans' | 'serif' | 'mono';
-export type HeadingWeight = 'light' | 'regular' | 'medium';
-export type HeadingSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs';
-
-type HeadingTextRenderer = [TText] extends [ReactNode]
- ? { renderText?: (text: TText) => ReactNode }
- : { renderText: (text: TText) => ReactNode };
-
-export type HeadingProps = {
+export type HeadingProps = {
as?: HeadingAs;
- children?: ReactNode;
- inlineSegmentSeparator?: ReactNode;
- segments?: HeadingType | HeadingType[];
- weight?: HeadingWeight;
- size?: HeadingSize;
+ children: ReactNode;
className?: string;
-} & HeadingTextRenderer;
+ size?: HeadingSize;
+ weight?: HeadingWeight;
+};
-export function Heading({
+export function Heading({
as: Tag = 'h2',
children,
- inlineSegmentSeparator = ' ',
- renderText,
- segments,
- weight = 'regular',
- size = 'md',
className,
-}: HeadingProps) {
+ size = 'md',
+ weight = 'regular',
+}: HeadingProps) {
const rootClassName = [headingRootClassName, className]
.filter(Boolean)
.join(' ');
- const renderSegmentText = (text: TText) =>
- renderText === undefined ? (text as ReactNode) : renderText(text);
return (
- {children !== undefined ? (
- children
- ) : Array.isArray(segments) ? (
- segments.map((segment, index) => {
- const lineBreakBefore =
- segment.newLine === true || segment.lineBreakBefore === true;
- const joinWithPreviousInlineSegment =
- index > 0 && lineBreakBefore === false;
-
- return (
-
- {lineBreakBefore ? : null}
- {joinWithPreviousInlineSegment ? inlineSegmentSeparator : null}
-
- {renderSegmentText(segment.text)}
-
-
- );
- })
- ) : segments !== undefined ? (
-
- {renderSegmentText(segments.text)}
-
- ) : null}
+ {children}
);
}
diff --git a/packages/twenty-website-new/src/design-system/components/Image.tsx b/packages/twenty-website-new/src/design-system/components/Image.tsx
index 4478b281c5..3784b8cbf2 100644
--- a/packages/twenty-website-new/src/design-system/components/Image.tsx
+++ b/packages/twenty-website-new/src/design-system/components/Image.tsx
@@ -1,5 +1,10 @@
-import NextImage from 'next/image';
+import { css } from '@linaria/core';
import { styled } from '@linaria/react';
+import NextImage from 'next/image';
+
+const coverImageClassName = css`
+ object-fit: cover;
+`;
export type ImageType = {
alt: string;
@@ -20,10 +25,10 @@ export function Image({ alt, className, src }: ImageComponentProps) {
);
diff --git a/packages/twenty-website-new/src/design-system/components/StepperProgressRail.tsx b/packages/twenty-website-new/src/design-system/components/StepperProgressRail.tsx
index 9f44093a5a..ef0ea62430 100644
--- a/packages/twenty-website-new/src/design-system/components/StepperProgressRail.tsx
+++ b/packages/twenty-website-new/src/design-system/components/StepperProgressRail.tsx
@@ -117,7 +117,10 @@ export function StepperProgressRail({
data-active={String(isActive)}
style={{ backgroundColor: inactiveColor }}
>
-
+
{String(index + 1).padStart(2, '0')}
diff --git a/packages/twenty-website-new/src/design-system/components/index.ts b/packages/twenty-website-new/src/design-system/components/index.ts
index 09cf1255c4..2ac9e37c4a 100644
--- a/packages/twenty-website-new/src/design-system/components/index.ts
+++ b/packages/twenty-website-new/src/design-system/components/index.ts
@@ -1,10 +1,8 @@
export { Body } from './Body';
export { LinkButton } from './Button/LinkButton';
-export { SubmitButton } from './Button/SubmitButton';
export { Container } from './Container';
export { Eyebrow } from './Eyebrow';
export { Form } from './Form';
-export type { FormFieldProps, FormTextareaProps } from './Form';
export { GuideCrosshair } from './GuideCrosshair';
export { Heading, HeadingPart } from './Heading';
export { IconButton } from './IconButton';
@@ -20,5 +18,4 @@ export type {
} from './Layout';
export { MarkdownProse } from './MarkdownProse';
export { Modal } from './Modal';
-export type { ModalRootProps } from './Modal';
export { StepperProgressRail } from './StepperProgressRail';
diff --git a/packages/twenty-website-new/src/icons/clients/act-education.tsx b/packages/twenty-website-new/src/icons/clients/ActEducation.tsx
similarity index 100%
rename from packages/twenty-website-new/src/icons/clients/act-education.tsx
rename to packages/twenty-website-new/src/icons/clients/ActEducation.tsx
diff --git a/packages/twenty-website-new/src/icons/clients/alternative-partners.tsx b/packages/twenty-website-new/src/icons/clients/AlternativePartners.tsx
similarity index 100%
rename from packages/twenty-website-new/src/icons/clients/alternative-partners.tsx
rename to packages/twenty-website-new/src/icons/clients/AlternativePartners.tsx
diff --git a/packages/twenty-website-new/src/icons/clients/elevate-consulting.tsx b/packages/twenty-website-new/src/icons/clients/ElevateConsulting.tsx
similarity index 100%
rename from packages/twenty-website-new/src/icons/clients/elevate-consulting.tsx
rename to packages/twenty-website-new/src/icons/clients/ElevateConsulting.tsx
diff --git a/packages/twenty-website-new/src/icons/clients/netzero.tsx b/packages/twenty-website-new/src/icons/clients/Netzero.tsx
similarity index 100%
rename from packages/twenty-website-new/src/icons/clients/netzero.tsx
rename to packages/twenty-website-new/src/icons/clients/Netzero.tsx
diff --git a/packages/twenty-website-new/src/icons/clients/nine-dots.tsx b/packages/twenty-website-new/src/icons/clients/NineDots.tsx
similarity index 100%
rename from packages/twenty-website-new/src/icons/clients/nine-dots.tsx
rename to packages/twenty-website-new/src/icons/clients/NineDots.tsx
diff --git a/packages/twenty-website-new/src/icons/clients/w3villa.tsx b/packages/twenty-website-new/src/icons/clients/W3Villa.tsx
similarity index 100%
rename from packages/twenty-website-new/src/icons/clients/w3villa.tsx
rename to packages/twenty-website-new/src/icons/clients/W3Villa.tsx
diff --git a/packages/twenty-website-new/src/icons/clients/index.ts b/packages/twenty-website-new/src/icons/clients/index.ts
index f311b32108..bf4434cdba 100644
--- a/packages/twenty-website-new/src/icons/clients/index.ts
+++ b/packages/twenty-website-new/src/icons/clients/index.ts
@@ -1,18 +1,18 @@
import type { ComponentType } from 'react';
-import { ActEducationIcon } from './act-education';
+import { ActEducationIcon } from './ActEducation';
import type { ClientIconProps } from './client-icon-props';
-import { AlternativePartnersIcon } from './alternative-partners';
-import { ElevateConsultingIcon } from './elevate-consulting';
-import { NetZeroIcon } from './netzero';
-import { NineDotsIcon } from './nine-dots';
-import { W3villaIcon } from './w3villa';
+import { AlternativePartnersIcon } from './AlternativePartners';
+import { ElevateConsultingIcon } from './ElevateConsulting';
+import { NetZeroIcon } from './Netzero';
+import { NineDotsIcon } from './NineDots';
+import { W3villaIcon } from './W3Villa';
-export { ActEducationIcon } from './act-education';
-export { AlternativePartnersIcon } from './alternative-partners';
-export { ElevateConsultingIcon } from './elevate-consulting';
-export { NetZeroIcon } from './netzero';
-export { NineDotsIcon } from './nine-dots';
-export { W3villaIcon } from './w3villa';
+export { ActEducationIcon } from './ActEducation';
+export { AlternativePartnersIcon } from './AlternativePartners';
+export { ElevateConsultingIcon } from './ElevateConsulting';
+export { NetZeroIcon } from './Netzero';
+export { NineDotsIcon } from './NineDots';
+export { W3villaIcon } from './W3Villa';
export type { ClientIconProps } from './client-icon-props';
export const CLIENT_ICONS: Record> = {
diff --git a/packages/twenty-website-new/src/lib/animation/animation-frame-loop.ts b/packages/twenty-website-new/src/lib/animation/animation-frame-loop.ts
index d0db2df347..a758046b3f 100644
--- a/packages/twenty-website-new/src/lib/animation/animation-frame-loop.ts
+++ b/packages/twenty-website-new/src/lib/animation/animation-frame-loop.ts
@@ -1,10 +1,10 @@
-export type AnimationFrameLoopController = {
+type AnimationFrameLoopController = {
isRunning: () => boolean;
start: () => void;
stop: () => void;
};
-export type CreateAnimationFrameLoopOptions = {
+type CreateAnimationFrameLoopOptions = {
cancelAnimationFrame?: (handle: number) => void;
onFrame: (timestamp: number) => boolean | void;
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
diff --git a/packages/twenty-website-new/src/lib/animation/index.ts b/packages/twenty-website-new/src/lib/animation/index.ts
index 8ee2cdaf0e..f96d2fdc28 100644
--- a/packages/twenty-website-new/src/lib/animation/index.ts
+++ b/packages/twenty-website-new/src/lib/animation/index.ts
@@ -1,17 +1,2 @@
-export {
- createAnimationFrameLoop,
- type AnimationFrameLoopController,
- type CreateAnimationFrameLoopOptions,
-} from './animation-frame-loop';
-export {
- DEFAULT_ANIMATED_NUMBER_DURATION_MS,
- easeOutCubic,
- getAnimatedNumberValue,
- type AnimatedNumberEasing,
- type AnimatedNumberRounder,
- type GetAnimatedNumberValueParameters,
-} from './animated-number';
-export {
- useAnimatedNumber,
- type UseAnimatedNumberOptions,
-} from './use-animated-number';
+export { createAnimationFrameLoop } from './animation-frame-loop';
+export { useAnimatedNumber } from './use-animated-number';
diff --git a/packages/twenty-website-new/src/lib/animation/use-animated-number.ts b/packages/twenty-website-new/src/lib/animation/use-animated-number.ts
index b88fde7d68..ea2b582bcf 100644
--- a/packages/twenty-website-new/src/lib/animation/use-animated-number.ts
+++ b/packages/twenty-website-new/src/lib/animation/use-animated-number.ts
@@ -13,7 +13,7 @@ import {
} from './animated-number';
import { createAnimationFrameLoop } from './animation-frame-loop';
-export type UseAnimatedNumberOptions = {
+type UseAnimatedNumberOptions = {
disabled?: boolean;
durationMs?: number;
easing?: AnimatedNumberEasing;
diff --git a/packages/twenty-website-new/src/lib/api/fetch-with-timeout.ts b/packages/twenty-website-new/src/lib/api/fetch-with-timeout.ts
index bc7bd68965..af82a9444a 100644
--- a/packages/twenty-website-new/src/lib/api/fetch-with-timeout.ts
+++ b/packages/twenty-website-new/src/lib/api/fetch-with-timeout.ts
@@ -1,4 +1,4 @@
-export type FetchWithTimeoutResult =
+type FetchWithTimeoutResult =
| { ok: true; response: Response }
| { ok: false; error: 'timeout' | 'network' };
diff --git a/packages/twenty-website-new/src/lib/api/index.ts b/packages/twenty-website-new/src/lib/api/index.ts
index c4ae4b8e27..0d2acb3d2b 100644
--- a/packages/twenty-website-new/src/lib/api/index.ts
+++ b/packages/twenty-website-new/src/lib/api/index.ts
@@ -1,8 +1,3 @@
export { fetchWithTimeout } from './fetch-with-timeout';
-export type { FetchWithTimeoutResult } from './fetch-with-timeout';
-
export { readJsonBody } from './read-json-body';
-export type { ReadJsonBodyResult } from './read-json-body';
-
export { createRateLimiter, getClientIpKey } from './rate-limit';
-export type { RateLimitOptions, RateLimitResult } from './rate-limit';
diff --git a/packages/twenty-website-new/src/lib/api/read-json-body.ts b/packages/twenty-website-new/src/lib/api/read-json-body.ts
index 43f726e26d..898d279714 100644
--- a/packages/twenty-website-new/src/lib/api/read-json-body.ts
+++ b/packages/twenty-website-new/src/lib/api/read-json-body.ts
@@ -1,4 +1,4 @@
-export type ReadJsonBodyResult =
+type ReadJsonBodyResult =
| { ok: true; value: T }
| { ok: false; error: 'invalid-json' | 'too-large' | 'wrong-content-type' };
diff --git a/packages/twenty-website-new/src/lib/articles/format-article-date.ts b/packages/twenty-website-new/src/lib/articles/format-article-date.ts
index 98be14be41..5455daaa57 100644
--- a/packages/twenty-website-new/src/lib/articles/format-article-date.ts
+++ b/packages/twenty-website-new/src/lib/articles/format-article-date.ts
@@ -1,13 +1,15 @@
-export function formatArticleDate(dateString: string): string {
- const date = new Date(dateString);
+const DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+});
- if (Number.isNaN(date.getTime())) {
- throw new Error(`Invalid article date: ${dateString}`);
+export function formatArticleDate(date: Date | string): string {
+ const resolved = typeof date === 'string' ? new Date(date) : date;
+
+ if (Number.isNaN(resolved.getTime())) {
+ throw new Error(`Invalid article date: ${String(date)}`);
}
- return new Intl.DateTimeFormat('en-US', {
- month: 'long',
- day: 'numeric',
- year: 'numeric',
- }).format(date);
+ return DATE_FORMATTER.format(resolved);
}
diff --git a/packages/twenty-website-new/src/lib/articles/index.ts b/packages/twenty-website-new/src/lib/articles/index.ts
index 28d4055988..4a34cb7438 100644
--- a/packages/twenty-website-new/src/lib/articles/index.ts
+++ b/packages/twenty-website-new/src/lib/articles/index.ts
@@ -2,7 +2,5 @@ export { formatArticleDate } from './format-article-date';
export {
getPublishedArticle,
getPublishedArticles,
- loadLocalArticles,
- loadLocalArticlesFromDirectory,
} from './load-local-articles';
export type { Article } from './types';
diff --git a/packages/twenty-website-new/src/lib/customers/case-study-catalog.ts b/packages/twenty-website-new/src/lib/customers/case-study-catalog.ts
index 83eac66d85..d5c0f4cf4d 100644
--- a/packages/twenty-website-new/src/lib/customers/case-study-catalog.ts
+++ b/packages/twenty-website-new/src/lib/customers/case-study-catalog.ts
@@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro';
import type { CaseStudyCatalogEntry } from '@/lib/customers/types';
import { theme } from '@/theme';
-const PLACEHOLDER_HERO = '/images/shared/people/avatars/katherine-adams.jpg';
+const PLACEHOLDER_HERO = '/images/shared/people/avatars/katherine-adams.webp';
export const CASE_STUDY_HALFTONE_PALETTE: readonly {
dashColor: string;
@@ -53,12 +53,9 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
},
hero: {
readingTime: '9 min',
- title: [
- { text: msg`A real estate agency on WhatsApp`, fontFamily: 'serif' },
- { text: msg`built a CRM around it`, fontFamily: 'sans' },
- ],
+ title: msg`A real estate agency on WhatsApp built a CRM around it`,
author: 'Mike Babiy & Azmat Parveen',
- authorAvatarSrc: '/images/partner/testimonials/mike-babiy.png',
+ authorAvatarSrc: '/images/partner/testimonials/mike-babiy.webp',
clientIcon: 'nine-dots',
heroImageSrc: PLACEHOLDER_HERO,
},
@@ -79,10 +76,7 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
],
hero: {
readingTime: '7 min',
- title: [
- { text: msg`From Salesforce to`, fontFamily: 'serif' },
- { text: msg`self-hosted Twenty`, fontFamily: 'sans' },
- ],
+ title: msg`From Salesforce to self-hosted Twenty`,
author: 'Benjamin Reynolds',
authorAvatarSrc: '/images/partner/testimonials/benjamin-reynolds.webp',
clientIcon: 'alternative-partners',
@@ -105,12 +99,9 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
],
hero: {
readingTime: '8 min',
- title: [
- { text: msg`A CRM that`, fontFamily: 'serif' },
- { text: msg`grows with you`, fontFamily: 'sans' },
- ],
+ title: msg`A CRM that grows with you`,
author: 'Olivier Reinaud',
- authorAvatarSrc: '/images/partner/testimonials/olivier-reinaud.jpg',
+ authorAvatarSrc: '/images/partner/testimonials/olivier-reinaud.webp',
clientIcon: 'netzero',
heroImageSrc: PLACEHOLDER_HERO,
},
@@ -128,12 +119,9 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
kpis: [{ value: msg`90%+`, label: msg`Lower CRM cost` }],
hero: {
readingTime: '7 min',
- title: [
- { text: msg`A CRM they`, fontFamily: 'serif' },
- { text: msg`actually own`, fontFamily: 'sans' },
- ],
+ title: msg`A CRM they actually own`,
author: 'Joseph Chiang',
- authorAvatarSrc: '/images/partner/testimonials/joseph-chiang.jpg',
+ authorAvatarSrc: '/images/partner/testimonials/joseph-chiang.webp',
clientIcon: 'act-education',
heroImageSrc: PLACEHOLDER_HERO,
},
@@ -151,10 +139,7 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
kpis: [{ value: msg`Zero`, label: msg`Manual work at core` }],
hero: {
readingTime: '8 min',
- title: [
- { text: msg`When your CRM`, fontFamily: 'serif' },
- { text: msg`is the product`, fontFamily: 'sans' },
- ],
+ title: msg`When your CRM is the product`,
author: 'Amrendra Pratap Singh',
authorAvatarSrc: '/images/partner/testimonials/amrendra-singh.webp',
clientIcon: 'w3villa',
@@ -183,10 +168,7 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
},
hero: {
readingTime: '8 min',
- title: [
- { text: msg`Twenty as the API backbone`, fontFamily: 'serif' },
- { text: msg`of a go-to-market stack`, fontFamily: 'sans' },
- ],
+ title: msg`Twenty as the API backbone of a go-to-market stack`,
author: 'Justin Beadle',
clientIcon: 'elevate-consulting',
heroImageSrc: PLACEHOLDER_HERO,
diff --git a/packages/twenty-website-new/src/lib/customers/types.ts b/packages/twenty-website-new/src/lib/customers/types.ts
index 5b71853a64..52ff42b490 100644
--- a/packages/twenty-website-new/src/lib/customers/types.ts
+++ b/packages/twenty-website-new/src/lib/customers/types.ts
@@ -1,10 +1,10 @@
-import type { MessageHeadingSegment } from '@/lib/i18n/message-heading-segment';
import type { MessageDescriptor } from '@lingui/core';
+import type { ReactNode } from 'react';
export type CaseStudyTextBlock = {
type: 'text';
eyebrow?: MessageDescriptor;
- heading: MessageHeadingSegment[];
+ heading: ReactNode;
paragraphs: MessageDescriptor[];
callout?: string;
};
@@ -32,7 +32,7 @@ export type CaseStudyData = {
meta: { title: MessageDescriptor; description: MessageDescriptor };
hero: {
readingTime: string;
- title: MessageHeadingSegment[];
+ title: ReactNode;
author: string;
authorAvatarSrc?: string;
clientIcon: string;
@@ -57,6 +57,13 @@ export type CaseStudyCatalogEntry = {
kpis: CaseStudyKpi[];
authorRole: MessageDescriptor;
quote?: CaseStudyQuote;
- hero: CaseStudyData['hero'];
+ hero: {
+ readingTime: string;
+ title: MessageDescriptor;
+ author: string;
+ authorAvatarSrc?: string;
+ clientIcon: string;
+ heroImageSrc: string;
+ };
catalogCard: CaseStudyData['catalogCard'];
};
diff --git a/packages/twenty-website-new/src/lib/enterprise/enterprise-jwt.ts b/packages/twenty-website-new/src/lib/enterprise/enterprise-jwt.ts
index ae105941f8..6e07aa7dfa 100644
--- a/packages/twenty-website-new/src/lib/enterprise/enterprise-jwt.ts
+++ b/packages/twenty-website-new/src/lib/enterprise/enterprise-jwt.ts
@@ -1,12 +1,12 @@
import * as crypto from 'crypto';
-export type EnterpriseKeyPayload = {
+type EnterpriseKeyPayload = {
sub: string;
licensee: string;
iat: number;
};
-export type EnterpriseValidityPayload = {
+type EnterpriseValidityPayload = {
sub: string;
status: 'valid';
iat: number;
@@ -32,7 +32,7 @@ const getValidityTokenDurationDays = (): number => {
return parsed;
};
-export type SignValidityTokenOptions = {
+type SignValidityTokenOptions = {
subscriptionCancelAt: number | null;
};
@@ -61,18 +61,11 @@ const getPrivateKey = (): string => {
};
const base64UrlEncode = (data: string): string => {
- return Buffer.from(data)
- .toString('base64')
- .replace(/\+/g, '-')
- .replace(/\//g, '_')
- .replace(/=+$/, '');
+ return Buffer.from(data).toString('base64url');
};
const base64UrlDecode = (data: string): string => {
- const padded = data + '='.repeat((4 - (data.length % 4)) % 4);
- const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
-
- return Buffer.from(base64, 'base64').toString('utf-8');
+ return Buffer.from(data, 'base64url').toString('utf-8');
};
const signJwt = (
@@ -89,10 +82,7 @@ const signJwt = (
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PADDING,
})
- .toString('base64')
- .replace(/\+/g, '-')
- .replace(/\//g, '_')
- .replace(/=+$/, '');
+ .toString('base64url');
return `${signingInput}.${signature}`;
};
@@ -111,11 +101,7 @@ export const verifyJwt = >(
const [encodedHeader, encodedPayload, signature] = parts;
const signingInput = `${encodedHeader}.${encodedPayload}`;
- const signatureBuffer = Buffer.from(
- signature.replace(/-/g, '+').replace(/_/g, '/') +
- '='.repeat((4 - (signature.length % 4)) % 4),
- 'base64',
- );
+ const signatureBuffer = Buffer.from(signature, 'base64url');
const isValid = crypto.verify(
'sha256',
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exporters.test.ts b/packages/twenty-website-new/src/lib/halftone/__tests__/exporters.test.ts
similarity index 97%
rename from packages/twenty-website-new/src/app/[locale]/halftone/_lib/exporters.test.ts
rename to packages/twenty-website-new/src/lib/halftone/__tests__/exporters.test.ts
index 0003e1e3db..102d9c3c15 100644
--- a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exporters.test.ts
+++ b/packages/twenty-website-new/src/lib/halftone/__tests__/exporters.test.ts
@@ -3,14 +3,14 @@ import {
generateStandaloneHtml,
parseExportedPreset,
type ReactExportSettings,
-} from '@/app/[locale]/halftone/_lib/exporters';
-import { resolveExportArtifactNames } from '@/app/[locale]/halftone/_lib/exportNames';
-import { REFERENCE_PREVIEW_DISTANCE } from '@/lib/halftone/footprint';
+} from '@/lib/halftone/utils/exporters';
+import { resolveExportArtifactNames } from '@/lib/halftone/utils/export-names';
+import { REFERENCE_PREVIEW_DISTANCE } from '@/lib/halftone/utils/footprint';
import {
DEFAULT_HALFTONE_SETTINGS,
normalizeHalftoneStudioSettings,
type HalftoneGeometrySpec,
-} from '@/lib/halftone/state';
+} from '@/lib/halftone/utils/state';
const IMPORTED_GLB_SHAPE: HalftoneGeometrySpec = {
key: 'userUpload_connect',
diff --git a/packages/twenty-website-new/src/lib/halftone/__tests__/footprint.test.ts b/packages/twenty-website-new/src/lib/halftone/__tests__/footprint.test.ts
index 3cb1d59761..f36a66676d 100644
--- a/packages/twenty-website-new/src/lib/halftone/__tests__/footprint.test.ts
+++ b/packages/twenty-website-new/src/lib/halftone/__tests__/footprint.test.ts
@@ -1,10 +1,10 @@
import {
- HALFTONE_FOOTPRINT_RUNTIME_SOURCE,
getContainedImageRect,
getImageFootprintScale,
getMeshFootprintScale,
REFERENCE_PREVIEW_DISTANCE,
-} from '@/lib/halftone/footprint';
+} from '@/lib/halftone/utils/footprint';
+import { HALFTONE_FOOTPRINT_RUNTIME_SOURCE } from '@/lib/halftone/generated/footprint-runtime-source';
import * as THREE from 'three';
describe('halftone footprint helpers', () => {
diff --git a/packages/twenty-website-new/src/lib/halftone/__tests__/interaction-state.test.ts b/packages/twenty-website-new/src/lib/halftone/__tests__/interaction-state.test.ts
index 6d499a6732..b8c61af68f 100644
--- a/packages/twenty-website-new/src/lib/halftone/__tests__/interaction-state.test.ts
+++ b/packages/twenty-website-new/src/lib/halftone/__tests__/interaction-state.test.ts
@@ -1,9 +1,9 @@
-import { DEFAULT_HALFTONE_SETTINGS } from '../state';
+import { DEFAULT_HALFTONE_SETTINGS } from '../utils/state';
import {
applySpringStep,
createHalftoneInteractionState,
resetHalftoneInteractionState,
-} from '../interaction-state';
+} from '../utils/interaction-state';
describe('halftone interaction state', () => {
it('hydrates pose fields while defaulting transient pointer state', () => {
diff --git a/packages/twenty-website-new/src/lib/halftone/__tests__/state.test.ts b/packages/twenty-website-new/src/lib/halftone/__tests__/state.test.ts
index 82c3ca8412..f571cf1b5b 100644
--- a/packages/twenty-website-new/src/lib/halftone/__tests__/state.test.ts
+++ b/packages/twenty-website-new/src/lib/halftone/__tests__/state.test.ts
@@ -6,7 +6,7 @@ import {
DEFAULT_SOLID_LIGHTING_SETTINGS,
normalizeHalftoneStudioSettings,
type HalftoneStudioSettings,
-} from '@/lib/halftone/state';
+} from '@/lib/halftone/utils/state';
describe('halftone studio state defaults', () => {
it('keeps the default scene aligned with the solid material preset', () => {
diff --git a/packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx b/packages/twenty-website-new/src/lib/halftone/components/HalftoneCanvas.tsx
similarity index 89%
rename from packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx
rename to packages/twenty-website-new/src/lib/halftone/components/HalftoneCanvas.tsx
index 894e6cf0a9..cd5f2e292e 100644
--- a/packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx
+++ b/packages/twenty-website-new/src/lib/halftone/components/HalftoneCanvas.tsx
@@ -6,7 +6,7 @@ import {
getMeshFootprintScale,
type HalftoneImageFit,
VIRTUAL_RENDER_HEIGHT,
-} from '@/lib/halftone/footprint';
+} from '@/lib/halftone/utils/footprint';
import { observeElementSize } from '@/lib/dom/observe-element-size';
import {
applyHalftoneMaterialSettings,
@@ -16,18 +16,18 @@ import {
type HalftoneMaterialAssets,
type HalftoneTransmissionMaterial,
renderHalftoneMaterialScene,
-} from '@/lib/halftone/materials';
+} from '@/lib/halftone/utils/materials';
import {
applySpringStep,
createHalftoneInteractionState,
resetHalftoneInteractionState,
type HalftoneInteractionState,
-} from '@/lib/halftone/interaction-state';
+} from '@/lib/halftone/utils/interaction-state';
import type {
HalftoneExportPose,
HalftoneStudioSettings,
-} from '@/lib/halftone/state';
-import { runCleanupTasks } from '@/lib/lifecycle/run-cleanup-tasks';
+} from '@/lib/halftone/utils/state';
+import { runCleanupTasks } from '@/lib/react/run-cleanup-tasks';
import { useLatestRef } from '@/lib/react';
import {
createSiteWebGlRenderer,
@@ -37,258 +37,20 @@ import {
} from '@/lib/visual-runtime';
import { styled } from '@linaria/react';
import { type MutableRefObject, useEffect, useRef } from 'react';
+import {
+ blurFragmentShader,
+ halftoneFragmentShader,
+ imagePassthroughFragmentShader,
+ passThroughVertexShader,
+} from '../utils/exporter-shaders';
+import {
+ IMAGE_HOVER_FADE_IN,
+ IMAGE_HOVER_FADE_OUT,
+ IMAGE_POINTER_FOLLOW,
+ IMAGE_POINTER_VELOCITY_DAMPING,
+} from '../constants/halftone-image-constants';
import * as THREE from 'three';
-const passThroughVertexShader = `
- varying vec2 vUv;
-
- void main() {
- vUv = uv;
- gl_Position = vec4(position, 1.0);
- }
-`;
-
-const blurFragmentShader = `
- precision highp float;
-
- uniform sampler2D tInput;
- uniform vec2 dir;
- uniform vec2 res;
-
- varying vec2 vUv;
-
- void main() {
- vec4 sum = vec4(0.0);
- vec2 px = dir / res;
-
- float w[5];
- w[0] = 0.227027;
- w[1] = 0.1945946;
- w[2] = 0.1216216;
- w[3] = 0.054054;
- w[4] = 0.016216;
-
- sum += texture2D(tInput, vUv) * w[0];
-
- for (int i = 1; i < 5; i++) {
- float fi = float(i) * 3.0;
- sum += texture2D(tInput, vUv + px * fi) * w[i];
- sum += texture2D(tInput, vUv - px * fi) * w[i];
- }
-
- gl_FragColor = sum;
- }
-`;
-
-const imagePassthroughFragmentShader = `
- precision highp float;
-
- uniform sampler2D tImage;
- uniform vec2 imageSize;
- uniform vec2 viewportSize;
- uniform float zoom;
- uniform float contrast;
- uniform float imageFit;
-
- varying vec2 vUv;
-
- void main() {
- float imageAspect = imageSize.x / imageSize.y;
- float viewAspect = viewportSize.x / viewportSize.y;
-
- vec2 uv = vUv;
-
- if (imageAspect > viewAspect) {
- float scale = viewAspect / imageAspect;
- if (imageFit > 0.5) {
- uv.x = (uv.x - 0.5) * scale + 0.5;
- } else {
- uv.y = (uv.y - 0.5) / scale + 0.5;
- }
- } else {
- float scale = imageAspect / viewAspect;
- if (imageFit > 0.5) {
- uv.y = (uv.y - 0.5) * scale + 0.5;
- } else {
- uv.x = (uv.x - 0.5) / scale + 0.5;
- }
- }
-
- uv = (uv - 0.5) / zoom + 0.5;
-
- 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 sampler2D tGlow;
- 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 time;
- uniform float waveAmount;
- uniform float waveSpeed;
- uniform float footprintScale;
- uniform vec2 interactionUv;
- uniform vec2 interactionVelocity;
- uniform vec2 dragOffset;
- uniform float hoverHalftoneActive;
- uniform float hoverHalftonePowerShift;
- uniform float hoverHalftoneRadius;
- uniform float hoverHalftoneWidthShift;
- uniform float hoverLightStrength;
- uniform float hoverLightRadius;
- uniform float hoverFlowStrength;
- uniform float hoverFlowRadius;
- uniform float dragFlowStrength;
- uniform float cropToBounds;
-
- 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);
- vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
- float velocityMagnitude = length(interactionVelocity);
- vec2 motionDir = velocityMagnitude > 0.001
- ? interactionVelocity / velocityMagnitude
- : vec2(0.0, 0.0);
- float motionBias = velocityMagnitude > 0.001
- ? dot(-radialDir, motionDir) * 0.5 + 0.5
- : 0.5;
-
- float hoverLightMask = 0.0;
- if (hoverLightStrength > 0.0) {
- float lightRadiusPx = hoverLightRadius * logicalResolution.y;
- hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
- }
-
- float hoverHalftoneMask = 0.0;
- if (hoverHalftoneActive > 0.0) {
- float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;
- hoverHalftoneMask =
- smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) *
- clamp(hoverHalftoneActive, 0.0, 1.0);
- }
-
- float hoverFlowMask = 0.0;
- if (hoverFlowStrength > 0.0) {
- float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;
- hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
- }
-
- vec2 hoverDisplacement =
- radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +
- motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15;
- vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;
- vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;
-
- float bandRow = floor(effectCoord.y / halftoneSize);
- float waveOffset =
- waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;
- effectCoord.x += waveOffset;
-
- 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 + hoverHalftonePowerShift * hoverHalftoneMask,
- -1.5,
- 1.5
- );
- float localWidth = clamp(
- s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,
- 0.05,
- 1.4
- );
- float lightLift =
- hoverLightStrength *
- hoverLightMask *
- mix(0.78, 1.18, motionBias) *
- 0.22;
- float toneValue =
- (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0);
- 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 + powerBias + 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;
- }
-
- vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);
- vec3 color = activeDashColor * alpha;
- gl_FragColor = vec4(color, alpha);
-
- #include
- #include
- }
-`;
-
-const IMAGE_POINTER_FOLLOW = 0.38;
-const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
-const IMAGE_HOVER_FADE_IN = 18;
-const IMAGE_HOVER_FADE_OUT = 7;
const MAX_PREVIEW_PIXEL_RATIO = 2;
const CanvasMount = styled.div<{ $background: string; $transparent: boolean }>`
diff --git a/packages/twenty-website-new/src/lib/halftone/halftone-image-canvas.tsx b/packages/twenty-website-new/src/lib/halftone/components/HalftoneImageCanvas.tsx
similarity index 95%
rename from packages/twenty-website-new/src/lib/halftone/halftone-image-canvas.tsx
rename to packages/twenty-website-new/src/lib/halftone/components/HalftoneImageCanvas.tsx
index 67eba9dd8e..d9abbfcea2 100644
--- a/packages/twenty-website-new/src/lib/halftone/halftone-image-canvas.tsx
+++ b/packages/twenty-website-new/src/lib/halftone/components/HalftoneImageCanvas.tsx
@@ -15,9 +15,12 @@ import {
HalftoneCanvas,
type HalftoneImageInteractionSettings,
type HalftoneSnapshotFn,
-} from './halftone-canvas';
-import type { HalftoneExportPose, HalftoneStudioSettings } from './state';
-import type { HalftoneImageFit } from './footprint';
+} from './HalftoneCanvas';
+import type {
+ HalftoneExportPose,
+ HalftoneStudioSettings,
+} from '../utils/state';
+import type { HalftoneImageFit } from '../utils/footprint';
type HalftoneImageCanvasProps = {
crossOrigin?: HTMLImageElement['crossOrigin'];
diff --git a/packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx b/packages/twenty-website-new/src/lib/halftone/components/HalftoneModelCanvas.tsx
similarity index 97%
rename from packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx
rename to packages/twenty-website-new/src/lib/halftone/components/HalftoneModelCanvas.tsx
index 3935ccb566..4fdafab460 100644
--- a/packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx
+++ b/packages/twenty-website-new/src/lib/halftone/components/HalftoneModelCanvas.tsx
@@ -13,17 +13,17 @@ import {
createFallbackGeometry,
loadImportedGeometryFromUrl,
type ImportedGeometryNormalizationOptions,
-} from './geometry-registry';
+} from '../utils/geometry-registry';
import {
HalftoneCanvas,
type HalftoneRenderStrategy,
type HalftoneSnapshotFn,
-} from './halftone-canvas';
+} from './HalftoneCanvas';
import type {
HalftoneExportPose,
HalftoneModelLoader,
HalftoneStudioSettings,
-} from './state';
+} from '../utils/state';
type HalftoneModelCanvasProps = {
geometryOptions?: ImportedGeometryNormalizationOptions;
diff --git a/packages/twenty-website-new/src/lib/halftone/constants/halftone-image-constants.ts b/packages/twenty-website-new/src/lib/halftone/constants/halftone-image-constants.ts
new file mode 100644
index 0000000000..02b87d5d87
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/halftone/constants/halftone-image-constants.ts
@@ -0,0 +1,4 @@
+export const IMAGE_POINTER_FOLLOW = 0.38;
+export const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
+export const IMAGE_HOVER_FADE_IN = 18;
+export const IMAGE_HOVER_FADE_OUT = 7;
diff --git a/packages/twenty-website-new/src/lib/halftone/generated/footprint-runtime-source.ts b/packages/twenty-website-new/src/lib/halftone/generated/footprint-runtime-source.ts
new file mode 100644
index 0000000000..905282b5d1
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/halftone/generated/footprint-runtime-source.ts
@@ -0,0 +1,248 @@
+// Untyped JS mirror of the footprint helpers in ../footprint.ts.
+// Injected into exported standalone HTML. Keep in sync — validated by
+// __tests__/footprint.test.ts which evals this string and compares results.
+import {
+ MIN_FOOTPRINT_SCALE,
+ REFERENCE_PREVIEW_DISTANCE,
+} from '../utils/footprint';
+
+export const HALFTONE_FOOTPRINT_RUNTIME_SOURCE = `
+const REFERENCE_PREVIEW_DISTANCE = ${REFERENCE_PREVIEW_DISTANCE};
+const MIN_FOOTPRINT_SCALE = ${MIN_FOOTPRINT_SCALE};
+
+function clampRectToViewport(rect, viewportWidth, viewportHeight) {
+ 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 {
+ x: minX,
+ y: minY,
+ width: maxX - minX,
+ height: maxY - minY,
+ };
+}
+
+function getRectArea(rect) {
+ if (!rect) {
+ return 0;
+ }
+
+ return Math.max(rect.width, 0) * Math.max(rect.height, 0);
+}
+
+function createBox3Corners(bounds) {
+ const { min, max } = bounds;
+
+ return [
+ new THREE.Vector3(min.x, min.y, min.z),
+ new THREE.Vector3(min.x, min.y, max.z),
+ new THREE.Vector3(min.x, max.y, min.z),
+ new THREE.Vector3(min.x, max.y, max.z),
+ new THREE.Vector3(max.x, min.y, min.z),
+ new THREE.Vector3(max.x, min.y, max.z),
+ new THREE.Vector3(max.x, max.y, min.z),
+ new THREE.Vector3(max.x, max.y, max.z),
+ ];
+}
+
+function getImagePreviewZoom(previewDistance) {
+ return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
+}
+
+function getContainedImageRect({
+ imageFit = 'contain',
+ imageHeight,
+ imageWidth,
+ viewportHeight,
+ viewportWidth,
+ zoom,
+}) {
+ if (
+ imageWidth <= 0 ||
+ imageHeight <= 0 ||
+ viewportWidth <= 0 ||
+ viewportHeight <= 0
+ ) {
+ return null;
+ }
+
+ const imageAspect = imageWidth / imageHeight;
+ const viewAspect = viewportWidth / viewportHeight;
+
+ let fittedWidth = viewportWidth;
+ let fittedHeight = viewportHeight;
+
+ if (imageAspect > viewAspect) {
+ if (imageFit === 'cover') {
+ fittedWidth = viewportHeight * imageAspect;
+ } else {
+ fittedHeight = viewportWidth / imageAspect;
+ }
+ } else {
+ if (imageFit === 'cover') {
+ fittedHeight = viewportWidth / imageAspect;
+ } else {
+ fittedWidth = viewportHeight * imageAspect;
+ }
+ }
+
+ const scaledWidth = fittedWidth * zoom;
+ const scaledHeight = fittedHeight * zoom;
+
+ return clampRectToViewport(
+ {
+ x: (viewportWidth - scaledWidth) * 0.5,
+ y: (viewportHeight - scaledHeight) * 0.5,
+ width: scaledWidth,
+ height: scaledHeight,
+ },
+ viewportWidth,
+ viewportHeight,
+ );
+}
+
+function getFootprintScaleFromRects(currentRect, referenceRect) {
+ 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({
+ imageFit = 'contain',
+ imageHeight,
+ imageWidth,
+ previewDistance,
+ viewportHeight,
+ viewportWidth,
+}) {
+ const currentRect = getContainedImageRect({
+ imageFit,
+ imageHeight,
+ imageWidth,
+ viewportHeight,
+ viewportWidth,
+ zoom: getImagePreviewZoom(previewDistance),
+ });
+ const referenceRect = getContainedImageRect({
+ imageFit,
+ imageHeight,
+ imageWidth,
+ viewportHeight,
+ viewportWidth,
+ zoom: 1,
+ });
+
+ return getFootprintScaleFromRects(currentRect, referenceRect);
+}
+
+function projectBox3ToViewport({
+ camera,
+ localBounds,
+ meshMatrixWorld,
+ viewportHeight,
+ viewportWidth,
+}) {
+ if (
+ localBounds.isEmpty() ||
+ viewportWidth <= 0 ||
+ viewportHeight <= 0
+ ) {
+ return null;
+ }
+
+ let minX = Number.POSITIVE_INFINITY;
+ let minY = Number.POSITIVE_INFINITY;
+ let maxX = Number.NEGATIVE_INFINITY;
+ let maxY = Number.NEGATIVE_INFINITY;
+ let hasProjectedCorner = false;
+
+ for (const corner of createBox3Corners(localBounds)) {
+ corner.applyMatrix4(meshMatrixWorld).project(camera);
+
+ if (
+ !Number.isFinite(corner.x) ||
+ !Number.isFinite(corner.y) ||
+ !Number.isFinite(corner.z)
+ ) {
+ continue;
+ }
+
+ hasProjectedCorner = true;
+
+ const x = (corner.x * 0.5 + 0.5) * viewportWidth;
+ const y = (1 - (corner.y * 0.5 + 0.5)) * viewportHeight;
+
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+
+ if (!hasProjectedCorner) {
+ return null;
+ }
+
+ return clampRectToViewport(
+ {
+ x: minX,
+ y: minY,
+ width: maxX - minX,
+ height: maxY - minY,
+ },
+ viewportWidth,
+ viewportHeight,
+ );
+}
+
+function getMeshFootprintScale({
+ camera,
+ localBounds,
+ lookAtTarget,
+ meshMatrixWorld,
+ viewportHeight,
+ viewportWidth,
+}) {
+ const currentRect = projectBox3ToViewport({
+ camera,
+ localBounds,
+ meshMatrixWorld,
+ viewportHeight,
+ viewportWidth,
+ });
+ const referenceCamera = camera.clone();
+ const currentOffset = referenceCamera.position.clone().sub(lookAtTarget);
+ const referenceOffset =
+ currentOffset.lengthSq() > 0
+ ? currentOffset.setLength(REFERENCE_PREVIEW_DISTANCE)
+ : new THREE.Vector3(0, 0, REFERENCE_PREVIEW_DISTANCE);
+
+ referenceCamera.position.copy(lookAtTarget).add(referenceOffset);
+ referenceCamera.lookAt(lookAtTarget);
+ referenceCamera.updateProjectionMatrix();
+ referenceCamera.updateMatrixWorld(true);
+
+ const referenceRect = projectBox3ToViewport({
+ camera: referenceCamera,
+ localBounds,
+ meshMatrixWorld,
+ viewportHeight,
+ viewportWidth,
+ });
+
+ return getFootprintScaleFromRects(currentRect, referenceRect);
+}
+`;
diff --git a/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts b/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts
deleted file mode 100644
index 49dddac6e6..0000000000
--- a/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts
+++ /dev/null
@@ -1,835 +0,0 @@
-import * as THREE from 'three';
-import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
-import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
-import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
-import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path';
-
-type HalftoneModelLoader = 'fbx' | 'glb';
-
-interface HalftoneGeometrySpec {
- key: string;
- label: string;
- kind: 'builtin' | 'imported';
- loader?: HalftoneModelLoader;
- filename?: string;
- description?: string;
- extensions?: readonly string[];
- userProvided?: boolean;
-}
-
-type GeometryCacheEntry = THREE.BufferGeometry | Promise;
-
-export type ImportedGeometryNormalizationOptions = {
- postRotateZ?: number;
- scaleTarget?: number;
- useLegacyNormalization?: boolean;
-};
-
-const LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET = 2.75;
-const importedGeometryCache = new Map();
-
-function getImportedGeometryCacheKey({
- geometryOptions,
- loader,
- modelUrl,
-}: {
- geometryOptions?: ImportedGeometryNormalizationOptions;
- loader: HalftoneModelLoader;
- modelUrl: string;
-}) {
- return JSON.stringify({
- geometryOptions: geometryOptions ?? null,
- loader,
- modelUrl,
- });
-}
-
-function mergeGeometries(geometries: THREE.BufferGeometry[]) {
- if (geometries.length === 1) {
- return geometries[0];
- }
-
- let totalVertices = 0;
- let totalIndices = 0;
- let hasUv = false;
-
- const geometryInfos = geometries.map((geometry) => {
- const position = geometry.attributes.position;
- const normal = geometry.attributes.normal;
- const uv = geometry.attributes.uv ?? null;
- const index = geometry.index;
- const indexCount = index ? index.count : position.count;
-
- totalVertices += position.count;
- totalIndices += indexCount;
- hasUv = hasUv || uv !== null;
-
- return {
- index,
- indexCount,
- normal,
- position,
- uv,
- vertexCount: position.count,
- };
- });
-
- const positions = new Float32Array(totalVertices * 3);
- const normals = new Float32Array(totalVertices * 3);
- const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
- const indices = new Uint32Array(totalIndices);
-
- let vertexOffset = 0;
- let indexOffset = 0;
-
- for (const geometryInfo of geometryInfos) {
- for (
- let vertexIndex = 0;
- vertexIndex < geometryInfo.vertexCount;
- vertexIndex += 1
- ) {
- const positionOffset = (vertexOffset + vertexIndex) * 3;
- positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
- positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
- positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
- normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
- normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
- normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
-
- if (uvs !== null) {
- const uvOffset = (vertexOffset + vertexIndex) * 2;
- uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
- uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
- }
- }
-
- if (geometryInfo.index) {
- for (
- let localIndex = 0;
- localIndex < geometryInfo.indexCount;
- localIndex += 1
- ) {
- indices[indexOffset + localIndex] =
- geometryInfo.index.getX(localIndex) + vertexOffset;
- }
- } else {
- for (
- let localIndex = 0;
- localIndex < geometryInfo.indexCount;
- localIndex += 1
- ) {
- indices[indexOffset + localIndex] = localIndex + vertexOffset;
- }
- }
-
- vertexOffset += geometryInfo.vertexCount;
- indexOffset += geometryInfo.indexCount;
- }
-
- const merged = new THREE.BufferGeometry();
- merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
- merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
-
- if (uvs !== null) {
- merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
- }
-
- merged.setIndex(new THREE.BufferAttribute(indices, 1));
-
- return merged;
-}
-
-const EMPTY_TEXTURE_DATA_URL =
- 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
-
-function normalizeImportedGeometry(
- geometry: THREE.BufferGeometry,
- options: ImportedGeometryNormalizationOptions = {},
-) {
- const {
- postRotateZ = 0,
- scaleTarget,
- useLegacyNormalization = false,
- } = options;
-
- geometry.computeBoundingBox();
-
- let boundingBox = geometry.boundingBox;
- let center = new THREE.Vector3();
- let size = new THREE.Vector3();
-
- boundingBox?.getCenter(center);
- boundingBox?.getSize(size);
- geometry.translate(-center.x, -center.y, -center.z);
-
- if (!useLegacyNormalization) {
- const dimensions = [size.x, size.y, size.z];
- const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
-
- if (thinnestAxis === 0) {
- geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
- } else if (thinnestAxis === 1) {
- geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
- }
- }
-
- geometry.computeBoundingBox();
- geometry.computeBoundingSphere();
-
- const radius = geometry.boundingSphere?.radius || 1;
- const scale = useLegacyNormalization
- ? (scaleTarget ?? LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET) /
- Math.max(size.x, size.y, size.z, 0.001)
- : (scaleTarget ?? 1.6) / radius;
- geometry.scale(scale, scale, scale);
-
- geometry.computeBoundingBox();
- boundingBox = geometry.boundingBox;
- center = new THREE.Vector3();
- boundingBox?.getCenter(center);
- geometry.translate(-center.x, -center.y, -center.z);
-
- if (postRotateZ !== 0) {
- geometry.rotateZ(postRotateZ);
- }
-
- geometry.computeVertexNormals();
- geometry.computeBoundingBox();
- geometry.computeBoundingSphere();
-
- return geometry;
-}
-
-function createLoadingManager() {
- const loadingManager = new THREE.LoadingManager();
- loadingManager.setURLModifier((url) =>
- /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
- );
-
- return loadingManager;
-}
-
-function extractMergedGeometry(
- root: THREE.Object3D,
- emptyMessage: string,
- geometryOptions?: ImportedGeometryNormalizationOptions,
-) {
- root.updateMatrixWorld(true);
-
- const geometries: THREE.BufferGeometry[] = [];
-
- root.traverse((object) => {
- if (!(object instanceof THREE.Mesh) || !object.geometry) {
- return;
- }
-
- const geometry = object.geometry.clone();
-
- if (!geometry.attributes.normal) {
- geometry.computeVertexNormals();
- }
-
- geometry.applyMatrix4(object.matrixWorld);
- geometries.push(geometry);
- });
-
- if (geometries.length === 0) {
- throw new Error(emptyMessage);
- }
-
- return normalizeImportedGeometry(
- mergeGeometries(geometries),
- geometryOptions,
- );
-}
-
-function parseFbxGeometry(
- buffer: ArrayBuffer,
- resourcePath: string,
- label: string,
- geometryOptions?: ImportedGeometryNormalizationOptions,
-) {
- const originalWarn = console.warn;
-
- console.warn = (...args: unknown[]) => {
- if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) {
- return;
- }
-
- originalWarn(...args);
- };
-
- try {
- const root = new FBXLoader(createLoadingManager()).parse(
- buffer,
- resourcePath,
- );
-
- return extractMergedGeometry(
- root,
- `${label} did not contain any mesh geometry.`,
- geometryOptions,
- );
- } finally {
- console.warn = originalWarn;
- }
-}
-
-function parseGlbGeometry(
- buffer: ArrayBuffer,
- resourcePath: string,
- label: string,
- geometryOptions?: ImportedGeometryNormalizationOptions,
-) {
- const dracoLoader = new DRACOLoader();
- dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
-
- const gltfLoader = new GLTFLoader(createLoadingManager());
- gltfLoader.setDRACOLoader(dracoLoader);
-
- return new Promise((resolve, reject) => {
- gltfLoader.parse(
- buffer,
- resourcePath,
- (gltf) => {
- try {
- resolve(
- extractMergedGeometry(
- gltf.scene,
- `${label} did not contain any mesh geometry.`,
- geometryOptions,
- ),
- );
- } catch (error) {
- reject(error);
- }
- },
- reject,
- );
- }).finally(() => {
- dracoLoader.dispose();
- });
-}
-
-async function loadImportedGeometry(
- loader: HalftoneModelLoader,
- file: File,
- label: string,
-) {
- const buffer = await file.arrayBuffer();
-
- if (loader === 'fbx') {
- return parseFbxGeometry(buffer, '', label);
- }
-
- return parseGlbGeometry(buffer, '', label);
-}
-
-export async function loadImportedGeometryFromUrl(
- loader: HalftoneModelLoader,
- modelUrl: string,
- label: string,
- geometryOptions?: ImportedGeometryNormalizationOptions,
-) {
- const cacheKey = getImportedGeometryCacheKey({
- geometryOptions,
- loader,
- modelUrl,
- });
- const cachedGeometry = importedGeometryCache.get(cacheKey);
-
- if (cachedGeometry) {
- return (await cachedGeometry).clone();
- }
-
- const geometryPromise = (async () => {
- const response = await fetch(modelUrl);
-
- if (!response.ok) {
- throw new Error(`Unable to load ${label} from ${modelUrl}.`);
- }
-
- const buffer = await response.arrayBuffer();
-
- if (loader === 'fbx') {
- return parseFbxGeometry(buffer, '', label, geometryOptions);
- }
-
- return parseGlbGeometry(buffer, '', label, geometryOptions);
- })();
- importedGeometryCache.set(cacheKey, geometryPromise);
-
- try {
- const geometry = await geometryPromise;
-
- importedGeometryCache.set(cacheKey, geometry);
-
- return geometry.clone();
- } catch (error) {
- importedGeometryCache.delete(cacheKey);
- throw error;
- }
-}
-
-function makePolarShape(
- radiusFunction: (angle: number) => number,
- segments = 320,
-) {
- const shape = new THREE.Shape();
-
- for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
- const angle = (segmentIndex / segments) * Math.PI * 2;
- const radius = radiusFunction(angle);
- const x = Math.cos(angle) * radius;
- const y = Math.sin(angle) * radius;
-
- if (segmentIndex === 0) {
- shape.moveTo(x, y);
- } else {
- shape.lineTo(x, y);
- }
- }
-
- return shape;
-}
-
-function makeReliefGeometry(
- shape: THREE.Shape,
- options: {
- depth?: number;
- bevelSize?: number;
- bevelThickness?: number;
- bevelSegments?: number;
- waves?: number;
- waveDepth?: number;
- } = {},
-) {
- const {
- bevelSegments = 8,
- bevelSize = 0.08,
- bevelThickness = 0.1,
- depth = 0.58,
- waveDepth = 0.016,
- waves = 8,
- } = options;
-
- const geometry = new THREE.ExtrudeGeometry(shape, {
- depth,
- steps: 2,
- bevelEnabled: true,
- bevelThickness,
- bevelSize,
- bevelSegments,
- curveSegments: 96,
- });
-
- geometry.center();
-
- const position = geometry.attributes.position;
- let maxRadius = 0;
-
- for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) {
- maxRadius = Math.max(
- maxRadius,
- Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)),
- );
- }
-
- const fullDepth = depth + bevelThickness * 2;
-
- for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) {
- const x = position.getX(vertexIndex);
- const y = position.getY(vertexIndex);
- const z = position.getZ(vertexIndex);
- const radius = Math.hypot(x, y) / maxRadius;
- const angle = Math.atan2(y, x);
- const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5));
- const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2));
- const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2));
- const wave =
- Math.cos(angle * waves) *
- Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) *
- waveDepth;
- const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave);
-
- position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief);
- }
-
- position.needsUpdate = true;
- geometry.computeVertexNormals();
- geometry.computeBoundingBox();
- geometry.computeBoundingSphere();
-
- return geometry;
-}
-
-function makeArrowTarget() {
- const targetParts: THREE.BufferGeometry[] = [];
- const arrowParts: THREE.BufferGeometry[] = [];
- const baseRadius = 1.35;
- const baseDepth = 0.32;
- const bevel = 0.12;
- const points: THREE.Vector2[] = [];
- const segments = 16;
-
- points.push(new THREE.Vector2(0, -baseDepth / 2));
- points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2));
-
- for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
- const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2);
- points.push(
- new THREE.Vector2(
- baseRadius - bevel + Math.cos(angle) * bevel,
- -baseDepth / 2 + bevel + Math.sin(angle) * bevel,
- ),
- );
- }
-
- points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel));
-
- for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
- const angle = (segmentIndex / segments) * (Math.PI / 2);
- points.push(
- new THREE.Vector2(
- baseRadius - bevel + Math.cos(angle) * bevel,
- baseDepth / 2 - bevel + Math.sin(angle) * bevel,
- ),
- );
- }
-
- points.push(new THREE.Vector2(0, baseDepth / 2));
-
- const disc = new THREE.LatheGeometry(points, 64);
- disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
- targetParts.push(disc);
-
- for (const radius of [0.45, 0.85, 1.22]) {
- const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64);
- ring.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04),
- );
- targetParts.push(ring);
- }
-
- const bump = new THREE.SphereGeometry(
- 0.32,
- 32,
- 24,
- 0,
- Math.PI * 2,
- 0,
- Math.PI / 2,
- );
- bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2));
- bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2));
- targetParts.push(bump);
-
- const shaftLength = 1.5;
- const shaftRadius = 0.05;
- const shaft = new THREE.CylinderGeometry(
- shaftRadius,
- shaftRadius,
- shaftLength,
- 10,
- 1,
- );
- shaft.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0),
- );
- arrowParts.push(shaft);
-
- const head = new THREE.ConeGeometry(0.12, 0.35, 10);
- head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0));
- arrowParts.push(head);
-
- for (let finIndex = 0; finIndex < 3; finIndex += 1) {
- const finShape = new THREE.Shape();
- finShape.moveTo(0, 0);
- finShape.lineTo(0.22, 0.25);
- finShape.lineTo(0, 0.5);
- finShape.lineTo(0, 0);
-
- const finGeometry = new THREE.ExtrudeGeometry(finShape, {
- depth: 0.012,
- bevelEnabled: false,
- });
-
- finGeometry.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0.05, 0, -0.006),
- );
- finGeometry.applyMatrix4(
- new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3),
- );
- finGeometry.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0),
- );
- arrowParts.push(finGeometry);
- }
-
- const nock = new THREE.SphereGeometry(0.065, 8, 8);
- nock.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0),
- );
- arrowParts.push(nock);
-
- const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15);
- const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5);
- const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12);
-
- for (const geometry of arrowParts) {
- geometry.applyMatrix4(aim);
- geometry.applyMatrix4(tilt);
- geometry.applyMatrix4(shift);
- }
-
- const merged = mergeGeometries([...targetParts, ...arrowParts]);
- merged.computeVertexNormals();
- merged.computeBoundingSphere();
-
- return merged;
-}
-
-function makeDollarCoin() {
- const parts: THREE.BufferGeometry[] = [];
- const baseRadius = 1.3;
- const baseDepth = 0.45;
- const bevel = 0.18;
- const points: THREE.Vector2[] = [];
- const segments = 20;
-
- points.push(new THREE.Vector2(0, -baseDepth / 2));
- points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2));
-
- for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
- const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI;
- points.push(
- new THREE.Vector2(
- baseRadius - bevel + Math.cos(angle) * bevel,
- Math.sin(angle) * (baseDepth / 2),
- ),
- );
- }
-
- points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2));
- points.push(new THREE.Vector2(0, baseDepth / 2));
-
- const disc = new THREE.LatheGeometry(points, 64);
- disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
- parts.push(disc);
-
- const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64);
- frontRim.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01),
- );
- parts.push(frontRim);
-
- const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64);
- backRim.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)),
- );
- parts.push(backRim);
-
- const createDollarSign = () => {
- const geometries: THREE.BufferGeometry[] = [];
- const tubeRadius = 0.1;
- const curveRadius = 0.28;
- const verticalOffset = 0.22;
-
- const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12);
- geometries.push(bar);
-
- const topArc = new THREE.TorusGeometry(
- curveRadius,
- tubeRadius,
- 16,
- 32,
- Math.PI,
- );
- topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
- topArc.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0),
- );
- geometries.push(topArc);
-
- const bottomArc = new THREE.TorusGeometry(
- curveRadius,
- tubeRadius,
- 16,
- 32,
- Math.PI,
- );
- bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2));
- bottomArc.applyMatrix4(
- new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0),
- );
- geometries.push(bottomArc);
-
- const topSerif = new THREE.CylinderGeometry(
- tubeRadius,
- tubeRadius,
- 0.22,
- 12,
- );
- topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
- topSerif.applyMatrix4(
- new THREE.Matrix4().makeTranslation(
- 0.16,
- verticalOffset + curveRadius,
- 0,
- ),
- );
- geometries.push(topSerif);
-
- const bottomSerif = new THREE.CylinderGeometry(
- tubeRadius,
- tubeRadius,
- 0.22,
- 12,
- );
- bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
- bottomSerif.applyMatrix4(
- new THREE.Matrix4().makeTranslation(
- -0.16,
- -verticalOffset - curveRadius,
- 0,
- ),
- );
- geometries.push(bottomSerif);
-
- const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2);
- const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1);
- const diagonal = new THREE.CylinderGeometry(
- tubeRadius,
- tubeRadius,
- diagonalLength + 0.12,
- 12,
- );
- diagonal.applyMatrix4(
- new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2),
- );
- geometries.push(diagonal);
-
- return geometries;
- };
-
- for (const geometry of createDollarSign()) {
- geometry.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01),
- );
- parts.push(geometry);
- }
-
- for (const geometry of createDollarSign()) {
- geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI));
- geometry.applyMatrix4(
- new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)),
- );
- parts.push(geometry);
- }
-
- const merged = mergeGeometries(parts);
- merged.computeVertexNormals();
- merged.computeBoundingSphere();
-
- return merged;
-}
-
-const BUILTIN_GEOMETRIES: Record THREE.BufferGeometry> = {
- torusKnot: () => new THREE.TorusKnotGeometry(1, 0.35, 200, 32),
- sphere: () => new THREE.SphereGeometry(1.4, 64, 64),
- torus: () => new THREE.TorusGeometry(1, 0.45, 64, 100),
- icosahedron: () => new THREE.IcosahedronGeometry(1.4, 4),
- box: () => new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6),
- cone: () => new THREE.ConeGeometry(1.2, 2.4, 64, 10),
- cylinder: () => new THREE.CylinderGeometry(1, 1, 2.3, 64, 10),
- octahedron: () => new THREE.OctahedronGeometry(1.5, 2),
- dodecahedron: () => new THREE.DodecahedronGeometry(1.35, 1),
- tetrahedron: () => new THREE.TetrahedronGeometry(1.7, 1),
- sunCoin: () =>
- makeReliefGeometry(
- makePolarShape(
- (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5),
- ),
- { depth: 0.62, waves: 12, waveDepth: 0.018 },
- ),
- lotusCoin: () =>
- makeReliefGeometry(
- makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)),
- { depth: 0.64, waves: 8, waveDepth: 0.014 },
- ),
- arrowTarget: () => makeArrowTarget(),
- dollarCoin: () => makeDollarCoin(),
-};
-
-async function createGeometry(
- spec: HalftoneGeometrySpec,
- file: File | undefined,
-) {
- if (spec.kind === 'imported') {
- if (!file || !spec.loader) {
- throw new Error(`No file is available for ${spec.label}.`);
- }
-
- return loadImportedGeometry(
- spec.loader as HalftoneModelLoader,
- file,
- spec.label,
- );
- }
-
- const factory = BUILTIN_GEOMETRIES[spec.key];
-
- if (!factory) {
- throw new Error(`Unsupported geometry: ${spec.key}.`);
- }
-
- return factory();
-}
-
-export function createFallbackGeometry() {
- return BUILTIN_GEOMETRIES.torusKnot();
-}
-
-export async function getGeometryForSpec(
- spec: HalftoneGeometrySpec,
- file: File | undefined,
- cache: Map,
-) {
- const cached = cache.get(spec.key);
-
- if (cached) {
- return cached instanceof Promise ? cached : Promise.resolve(cached);
- }
-
- const created = createGeometry(spec, file);
-
- if (created instanceof Promise) {
- const pending = created
- .then((geometry) => {
- cache.set(spec.key, geometry);
- return geometry;
- })
- .catch((error) => {
- cache.delete(spec.key);
- throw error;
- });
-
- cache.set(spec.key, pending);
-
- return pending;
- }
-
- cache.set(spec.key, created);
-
- return Promise.resolve(created);
-}
-
-export function disposeGeometryCache(cache: Map) {
- for (const value of cache.values()) {
- if (!(value instanceof Promise)) {
- value.dispose();
- }
- }
-
- cache.clear();
-}
diff --git a/packages/twenty-website-new/src/lib/halftone/index.ts b/packages/twenty-website-new/src/lib/halftone/index.ts
index f245679e54..cc2e735655 100644
--- a/packages/twenty-website-new/src/lib/halftone/index.ts
+++ b/packages/twenty-website-new/src/lib/halftone/index.ts
@@ -3,15 +3,15 @@ export {
type HalftoneImageInteractionSettings,
type HalftoneRenderStrategy,
type HalftoneSnapshotFn,
-} from './halftone-canvas';
-export { HalftoneImageCanvas } from './halftone-image-canvas';
-export { HalftoneModelCanvas } from './halftone-model-canvas';
+} from './components/HalftoneCanvas';
+export { HalftoneImageCanvas } from './components/HalftoneImageCanvas';
+export { HalftoneModelCanvas } from './components/HalftoneModelCanvas';
export {
createFallbackGeometry,
loadImportedGeometryFromUrl,
type ImportedGeometryNormalizationOptions,
-} from './geometry-registry';
+} from './utils/geometry-registry';
export {
DEFAULT_GLASS_ANIMATION_SETTINGS,
@@ -26,7 +26,7 @@ export {
DEFAULT_SOLID_LIGHTING_SETTINGS,
DEFAULT_SOLID_MATERIAL_SETTINGS,
normalizeHalftoneStudioSettings,
-} from './state';
+} from './utils/state';
export type {
HalftoneAnimationSettings,
@@ -40,7 +40,7 @@ export type {
HalftoneStudioSettings,
HalftoneStudioSettingsOverrides,
HalftoneToneTarget,
-} from './state';
+} from './utils/state';
export {
getContainedImageRect,
@@ -48,7 +48,8 @@ export {
getImagePreviewZoom,
getMeshFootprintScale,
type HalftoneImageFit,
- HALFTONE_FOOTPRINT_RUNTIME_SOURCE,
REFERENCE_PREVIEW_DISTANCE,
VIRTUAL_RENDER_HEIGHT,
-} from './footprint';
+} from './utils/footprint';
+
+export { HALFTONE_FOOTPRINT_RUNTIME_SOURCE } from './generated/footprint-runtime-source';
diff --git a/packages/twenty-website-new/src/lib/halftone/types/exporter-types.ts b/packages/twenty-website-new/src/lib/halftone/types/exporter-types.ts
new file mode 100644
index 0000000000..b3a68cc75a
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/halftone/types/exporter-types.ts
@@ -0,0 +1,52 @@
+import type {
+ HalftoneExportPose,
+ HalftoneGeometrySpec,
+ HalftoneStudioSettings,
+} from '@/lib/halftone/utils/state';
+
+export type ReactExportSettings = {
+ includeNamedAndDefaultExport: boolean;
+ includePublicAssetUrl: boolean;
+ includeRegistryComment: boolean;
+ includeTsNoCheck: boolean;
+ includeUseClientDirective: boolean;
+ includeStyledMount: boolean;
+};
+
+export type ReactExportOptions = {
+ assetUrl?: string;
+ background?: string;
+ exportSettings?: Partial;
+ imageFilename?: string;
+ importedFile?: File;
+ initialPose?: HalftoneExportPose;
+ modelFilenameOverride?: string;
+ previewDistance?: number;
+};
+
+export const DEFAULT_REACT_EXPORT_SETTINGS: ReactExportSettings = {
+ includeNamedAndDefaultExport: true,
+ includePublicAssetUrl: true,
+ includeRegistryComment: true,
+ includeTsNoCheck: true,
+ includeUseClientDirective: true,
+ includeStyledMount: true,
+};
+
+export type ExportedShapeDescriptor = {
+ filename: string | null;
+ key: string;
+ kind: HalftoneGeometrySpec['kind'];
+ label: string;
+ loader: HalftoneGeometrySpec['loader'] | null;
+};
+
+export type ParsedExportedPreset = {
+ componentName: string | null;
+ imageAssetReference: string | null;
+ initialPose: HalftoneExportPose;
+ modelAssetReference: string | null;
+ previewDistance: number;
+ settings: HalftoneStudioSettings;
+ shape: ExportedShapeDescriptor;
+};
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exportNames.ts b/packages/twenty-website-new/src/lib/halftone/utils/export-names.ts
similarity index 96%
rename from packages/twenty-website-new/src/app/[locale]/halftone/_lib/exportNames.ts
rename to packages/twenty-website-new/src/lib/halftone/utils/export-names.ts
index c81dec8f6a..4c42a71d9e 100644
--- a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/exportNames.ts
+++ b/packages/twenty-website-new/src/lib/halftone/utils/export-names.ts
@@ -25,7 +25,7 @@ export function normalizeExportComponentName(
return toPascalCase(value?.trim() || fallback);
}
-export function toKebabCase(value: string) {
+function toKebabCase(value: string) {
const normalized = value
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
diff --git a/packages/twenty-website-new/src/lib/halftone/utils/exporter-mount-fragments.ts b/packages/twenty-website-new/src/lib/halftone/utils/exporter-mount-fragments.ts
new file mode 100644
index 0000000000..6dccec81eb
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/halftone/utils/exporter-mount-fragments.ts
@@ -0,0 +1,177 @@
+// Composable string-returning functions for the exported mount scripts.
+// Used by createMountScript() and createImageMountScript() to avoid
+// maintaining parallel blocks of identical runtime JavaScript.
+
+export function emitSizingHelpers() {
+ return `
+ 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,
+ );`;
+}
+
+export function emitRendererSetup(cursor: string) {
+ return `
+ // boundary-allow-next-line:no-raw-webgl-renderer -- emitted into the standalone HTML export; runs in the user's downloaded file with no access to lib/visual-runtime
+ const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.setPixelRatio(1);
+ renderer.setClearColor(0x000000, 0);
+ renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
+
+ const canvas = renderer.domElement;
+ canvas.style.cursor = ${JSON.stringify(cursor)};
+ canvas.style.display = 'block';
+ canvas.style.height = '100%';
+ canvas.style.touchAction = 'none';
+ canvas.style.width = '100%';
+ container.appendChild(canvas);`;
+}
+
+export function emitBlurMaterials() {
+ return `
+ const blurHorizontalMaterial = new THREE.ShaderMaterial({
+ uniforms: {
+ tInput: { value: null },
+ dir: { value: new THREE.Vector2(1, 0) },
+ res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
+ },
+ vertexShader: passThroughVertexShader,
+ fragmentShader: blurFragmentShader,
+ });
+
+ const blurVerticalMaterial = new THREE.ShaderMaterial({
+ uniforms: {
+ tInput: { value: null },
+ dir: { value: new THREE.Vector2(0, 1) },
+ res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
+ },
+ vertexShader: passThroughVertexShader,
+ fragmentShader: blurFragmentShader,
+ });`;
+}
+
+type HalftoneUniformOverrides = {
+ cropToBounds: number;
+ hoverHalftoneRadius: string;
+ hoverLightRadius: string;
+ waveSpeed: string;
+};
+
+export function emitHalftoneMaterial(overrides: HalftoneUniformOverrides) {
+ return `
+ const halftoneMaterial = new THREE.ShaderMaterial({
+ transparent: true,
+ uniforms: {
+ tScene: { value: sceneTarget.texture },
+ tGlow: { value: blurTargetB.texture },
+ effectResolution: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ logicalResolution: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ tile: { value: settings.halftone.scale },
+ s_3: { value: settings.halftone.power },
+ s_4: { value: settings.halftone.width },
+ applyToDarkAreas: {
+ value: settings.halftone.toneTarget === 'dark' ? 1 : 0,
+ },
+ dashColor: { value: new THREE.Color(settings.halftone.dashColor) },
+ hoverDashColor: {
+ value: new THREE.Color(settings.halftone.hoverDashColor),
+ },
+ time: { value: 0 },
+ waveAmount: { value: 0 },
+ waveSpeed: { value: ${overrides.waveSpeed} },
+ footprintScale: { value: 1.0 },
+ interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
+ interactionVelocity: { value: new THREE.Vector2(0, 0) },
+ dragOffset: { value: new THREE.Vector2(0, 0) },
+ hoverHalftoneActive: { value: 0 },
+ hoverHalftonePowerShift: { value: 0 },
+ hoverHalftoneRadius: { value: ${overrides.hoverHalftoneRadius} },
+ hoverHalftoneWidthShift: { value: 0 },
+ hoverLightStrength: { value: 0 },
+ hoverLightRadius: { value: ${overrides.hoverLightRadius} },
+ hoverFlowStrength: { value: 0 },
+ hoverFlowRadius: { value: 0.18 },
+ dragFlowStrength: { value: 0 },
+ cropToBounds: { value: ${overrides.cropToBounds} },
+ },
+ vertexShader: passThroughVertexShader,
+ fragmentShader: halftoneFragmentShader,
+ });`;
+}
+
+export function emitPostScenes() {
+ return `
+ const blurHorizontalScene = new THREE.Scene();
+ blurHorizontalScene.add(
+ new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
+ );
+
+ const blurVerticalScene = new THREE.Scene();
+ blurVerticalScene.add(new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial));
+
+ const postScene = new THREE.Scene();
+ postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));`;
+}
+
+export function emitBlurPasses() {
+ return `
+ blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
+ renderer.setRenderTarget(blurTargetA);
+ renderer.render(blurHorizontalScene, orthographicCamera);
+
+ blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
+ renderer.setRenderTarget(blurTargetB);
+ renderer.render(blurVerticalScene, orthographicCamera);
+
+ blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
+ renderer.setRenderTarget(blurTargetA);
+ renderer.render(blurHorizontalScene, orthographicCamera);
+
+ blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
+ renderer.setRenderTarget(blurTargetB);
+ renderer.render(blurVerticalScene, orthographicCamera);
+
+ renderer.setRenderTarget(null);
+ renderer.clear();
+ renderer.render(postScene, orthographicCamera);`;
+}
+
+export function emitTimerSetup() {
+ return `
+ const clock = new THREE.Timer();
+ clock.connect(document);
+ let animationFrameId = 0;`;
+}
+
+export function emitCleanup(extraDispose: string, extraListeners: string) {
+ return `
+ return () => {
+ // boundary-allow-next-line:no-raw-animation-frame -- emitted into standalone HTML export
+ window.cancelAnimationFrame(animationFrameId);
+ clock.dispose();
+ resizeObserver.disconnect();
+${extraListeners}
+ blurHorizontalMaterial.dispose();
+ blurVerticalMaterial.dispose();
+ halftoneMaterial.dispose();
+ fullScreenGeometry.dispose();
+${extraDispose}
+ sceneTarget.dispose();
+ blurTargetA.dispose();
+ blurTargetB.dispose();
+ renderer.dispose();
+
+ if (canvas.parentNode === container) {
+ container.removeChild(canvas);
+ }
+ };`;
+}
diff --git a/packages/twenty-website-new/src/lib/halftone/utils/exporter-parsing.ts b/packages/twenty-website-new/src/lib/halftone/utils/exporter-parsing.ts
new file mode 100644
index 0000000000..95e831d73e
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/halftone/utils/exporter-parsing.ts
@@ -0,0 +1,195 @@
+import { normalizeExportComponentName } from '@/lib/halftone/utils/export-names';
+import { REFERENCE_PREVIEW_DISTANCE } from '@/lib/halftone/utils/footprint';
+import {
+ LEGACY_HALFTONE_SETTING_KEYS,
+ isRoundedBandHalftoneSettings,
+ type HalftoneExportPose,
+ type HalftoneGeometrySpec,
+ type HalftoneStudioSettings,
+} from '@/lib/halftone/utils/state';
+
+import type {
+ ExportedShapeDescriptor,
+ ParsedExportedPreset,
+} from '../types/exporter-types';
+
+function createDefaultExportPose(): HalftoneExportPose {
+ return {
+ autoElapsed: 0,
+ rotateElapsed: 0,
+ rotationX: 0,
+ rotationY: 0,
+ rotationZ: 0,
+ targetRotationX: 0,
+ targetRotationY: 0,
+ timeElapsed: 0,
+ };
+}
+
+export function normalizeExportPose(
+ pose: HalftoneExportPose | undefined,
+): HalftoneExportPose {
+ const fallback = createDefaultExportPose();
+
+ if (!pose) {
+ return fallback;
+ }
+
+ return {
+ autoElapsed: Number.isFinite(pose.autoElapsed) ? pose.autoElapsed : 0,
+ rotateElapsed: Number.isFinite(pose.rotateElapsed) ? pose.rotateElapsed : 0,
+ rotationX: Number.isFinite(pose.rotationX) ? pose.rotationX : 0,
+ rotationY: Number.isFinite(pose.rotationY) ? pose.rotationY : 0,
+ rotationZ: Number.isFinite(pose.rotationZ) ? pose.rotationZ : 0,
+ targetRotationX: Number.isFinite(pose.targetRotationX)
+ ? pose.targetRotationX
+ : 0,
+ targetRotationY: Number.isFinite(pose.targetRotationY)
+ ? pose.targetRotationY
+ : 0,
+ timeElapsed: Number.isFinite(pose.timeElapsed) ? pose.timeElapsed : 0,
+ };
+}
+
+export function normalizePreviewDistance(previewDistance: number | undefined) {
+ return Number.isFinite(previewDistance)
+ ? Math.max(previewDistance ?? REFERENCE_PREVIEW_DISTANCE, 0.001)
+ : REFERENCE_PREVIEW_DISTANCE;
+}
+
+function extractSerializedJson(
+ content: string,
+ name: string,
+ nextName: string,
+) {
+ const pattern = new RegExp(
+ String.raw`const\s+${name}\s*=\s*([\s\S]*?)\s*;\s*\r?\nconst\s+${nextName}\s*=`,
+ );
+ const match = content.match(pattern);
+
+ if (!match) {
+ throw new Error(`Could not find ${name} in the exported preset.`);
+ }
+
+ try {
+ return JSON.parse(match[1]) as T;
+ } catch (error) {
+ throw new Error(
+ error instanceof Error
+ ? `Could not parse ${name}: ${error.message}`
+ : `Could not parse ${name}.`,
+ );
+ }
+}
+
+function extractFirstMatch(content: string, patterns: RegExp[]) {
+ for (const pattern of patterns) {
+ const match = content.match(pattern);
+
+ if (match && match.length > 1) {
+ return match[match.length - 1] ?? null;
+ }
+ }
+
+ return null;
+}
+
+function assertRoundedBandPreset(
+ settings: HalftoneStudioSettings,
+): asserts settings is HalftoneStudioSettings {
+ if (isRoundedBandHalftoneSettings(settings.halftone)) {
+ return;
+ }
+
+ const halftoneValue =
+ settings.halftone && typeof settings.halftone === 'object'
+ ? (settings.halftone as Record)
+ : null;
+ const detectedLegacyKeys = LEGACY_HALFTONE_SETTING_KEYS.filter((key) =>
+ halftoneValue ? key in halftoneValue : false,
+ );
+ const suffix =
+ detectedLegacyKeys.length > 0
+ ? ` Detected legacy keys: ${detectedLegacyKeys.join(', ')}.`
+ : '';
+
+ throw new Error(
+ `This preset uses the legacy halftone effect and can no longer be imported.${suffix}`,
+ );
+}
+
+export function parseExportedPreset(content: string): ParsedExportedPreset {
+ const settings = extractSerializedJson(
+ content,
+ 'settings',
+ 'shape',
+ );
+ assertRoundedBandPreset(settings);
+ const shape = extractSerializedJson(
+ content,
+ 'shape',
+ 'initialPose',
+ );
+ const initialPose = normalizeExportPose(
+ (() => {
+ try {
+ return extractSerializedJson(
+ content,
+ 'initialPose',
+ 'previewDistance',
+ );
+ } catch {
+ return extractSerializedJson(
+ content,
+ 'initialPose',
+ 'VIRTUAL_RENDER_HEIGHT',
+ );
+ }
+ })(),
+ );
+ const previewDistanceMatch = content.match(
+ /const\s+previewDistance\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*;/,
+ );
+ const previewDistance = normalizePreviewDistance(
+ previewDistanceMatch ? Number(previewDistanceMatch[1]) : undefined,
+ );
+ const componentName =
+ extractFirstMatch(content, [
+ /export\s+default\s+function\s+([A-Za-z0-9_]+)/,
+ /export\s+function\s+([A-Za-z0-9_]+)/,
+ /export\s+default\s+([A-Za-z0-9_]+)\s*;/,
+ /([^<]+)<\/title>/i,
+ ]) ?? null;
+ const modelAssetReference = extractFirstMatch(content, [
+ /modelUrl\s*=\s*(['"`])([\s\S]*?)\1/,
+ /modelUrl\s*:\s*(['"`])([\s\S]*?)\1/,
+ ]);
+ const imageAssetReference = extractFirstMatch(content, [
+ /imageUrl\s*=\s*(['"`])([\s\S]*?)\1/,
+ /imageUrl\s*:\s*(['"`])([\s\S]*?)\1/,
+ ]);
+
+ return {
+ componentName,
+ imageAssetReference,
+ initialPose,
+ modelAssetReference,
+ previewDistance,
+ settings,
+ shape,
+ };
+}
+
+export function deriveExportComponentName(
+ shape: HalftoneGeometrySpec | undefined,
+ importedFile: File | undefined,
+) {
+ const source =
+ importedFile?.name ??
+ shape?.filename ??
+ shape?.label ??
+ shape?.key ??
+ 'HalftoneDashes';
+
+ return normalizeExportComponentName(source);
+}
diff --git a/packages/twenty-website-new/src/lib/halftone/utils/exporter-shaders.ts b/packages/twenty-website-new/src/lib/halftone/utils/exporter-shaders.ts
new file mode 100644
index 0000000000..79cf551cf1
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/halftone/utils/exporter-shaders.ts
@@ -0,0 +1,1585 @@
+import { GLASS_ENVIRONMENT_DATA_URL } from '@/lib/halftone/utils/glass-environment-data';
+import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/utils/draco-decoder-path';
+import type { HalftoneGeometrySpec } from '@/lib/halftone/utils/state';
+
+import type { ExportedShapeDescriptor } from '../types/exporter-types';
+
+export const passThroughVertexShader = `
+ varying vec2 vUv;
+
+ void main() {
+ vUv = uv;
+ gl_Position = vec4(position, 1.0);
+ }
+`;
+
+export const blurFragmentShader = `
+ precision highp float;
+
+ uniform sampler2D tInput;
+ uniform vec2 dir;
+ uniform vec2 res;
+
+ varying vec2 vUv;
+
+ void main() {
+ vec4 sum = vec4(0.0);
+ vec2 px = dir / res;
+
+ float w[5];
+ w[0] = 0.227027;
+ w[1] = 0.1945946;
+ w[2] = 0.1216216;
+ w[3] = 0.054054;
+ w[4] = 0.016216;
+
+ sum += texture2D(tInput, vUv) * w[0];
+
+ for (int i = 1; i < 5; i++) {
+ float fi = float(i) * 3.0;
+ sum += texture2D(tInput, vUv + px * fi) * w[i];
+ sum += texture2D(tInput, vUv - px * fi) * w[i];
+ }
+
+ gl_FragColor = sum;
+ }
+`;
+
+export const imagePassthroughFragmentShader = `
+ precision highp float;
+
+ uniform sampler2D tImage;
+ uniform vec2 imageSize;
+ uniform vec2 viewportSize;
+ uniform float zoom;
+ uniform float contrast;
+ uniform float imageFit;
+
+ varying vec2 vUv;
+
+ void main() {
+ float imageAspect = imageSize.x / imageSize.y;
+ float viewAspect = viewportSize.x / viewportSize.y;
+
+ vec2 uv = vUv;
+
+ if (imageAspect > viewAspect) {
+ float scale = viewAspect / imageAspect;
+ if (imageFit > 0.5) {
+ uv.x = (uv.x - 0.5) * scale + 0.5;
+ } else {
+ uv.y = (uv.y - 0.5) / scale + 0.5;
+ }
+ } else {
+ float scale = imageAspect / viewAspect;
+ if (imageFit > 0.5) {
+ uv.y = (uv.y - 0.5) * scale + 0.5;
+ } else {
+ uv.x = (uv.x - 0.5) / scale + 0.5;
+ }
+ }
+
+ uv = (uv - 0.5) / zoom + 0.5;
+
+ 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);
+ }
+`;
+
+export const halftoneFragmentShader = `
+ precision highp float;
+
+ uniform sampler2D tScene;
+ uniform sampler2D tGlow;
+ 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 time;
+ uniform float waveAmount;
+ uniform float waveSpeed;
+ uniform float footprintScale;
+ uniform vec2 interactionUv;
+ uniform vec2 interactionVelocity;
+ uniform vec2 dragOffset;
+ uniform float hoverHalftoneActive;
+ uniform float hoverHalftonePowerShift;
+ uniform float hoverHalftoneRadius;
+ uniform float hoverHalftoneWidthShift;
+ uniform float hoverLightStrength;
+ uniform float hoverLightRadius;
+ uniform float hoverFlowStrength;
+ uniform float hoverFlowRadius;
+ uniform float dragFlowStrength;
+ uniform float cropToBounds;
+
+ 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);
+ vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
+ float velocityMagnitude = length(interactionVelocity);
+ vec2 motionDir = velocityMagnitude > 0.001
+ ? interactionVelocity / velocityMagnitude
+ : vec2(0.0, 0.0);
+ float motionBias = velocityMagnitude > 0.001
+ ? dot(-radialDir, motionDir) * 0.5 + 0.5
+ : 0.5;
+
+ float hoverLightMask = 0.0;
+ if (hoverLightStrength > 0.0) {
+ float lightRadiusPx = hoverLightRadius * logicalResolution.y;
+ hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
+ }
+
+ float hoverHalftoneMask = 0.0;
+ if (hoverHalftoneActive > 0.0) {
+ float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;
+ hoverHalftoneMask =
+ smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) *
+ clamp(hoverHalftoneActive, 0.0, 1.0);
+ }
+
+ float hoverFlowMask = 0.0;
+ if (hoverFlowStrength > 0.0) {
+ float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;
+ hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
+ }
+
+ vec2 hoverDisplacement =
+ radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +
+ motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15;
+ vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;
+ vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;
+
+ float bandRow = floor(effectCoord.y / halftoneSize);
+ float waveOffset =
+ waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;
+ effectCoord.x += waveOffset;
+
+ 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 + hoverHalftonePowerShift * hoverHalftoneMask,
+ -1.5,
+ 1.5
+ );
+ float localWidth = clamp(
+ s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,
+ 0.05,
+ 1.4
+ );
+ float lightLift =
+ hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
+ float toneValue =
+ (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0);
+ 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 + powerBias + 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;
+ }
+
+ vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);
+ vec3 color = activeDashColor * alpha;
+ gl_FragColor = vec4(color, alpha);
+
+ #include
+ #include
+ }
+`;
+
+const HALFTONE_TRANSMISSION_SHADER_PREFIX = String.raw`
+uniform float chromaticAberration;
+uniform float anisotropicBlur;
+uniform float time;
+uniform float distortion;
+uniform float distortionScale;
+uniform float temporalDistortion;
+uniform sampler2D buffer;
+
+vec3 random3(vec3 c) {
+ float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0)));
+ vec3 r;
+ r.z = fract(512.0 * j);
+ j *= 0.125;
+ r.x = fract(512.0 * j);
+ j *= 0.125;
+ r.y = fract(512.0 * j);
+ return r - 0.5;
+}
+
+uint hash(uint x) {
+ x += (x << 10u);
+ x ^= (x >> 6u);
+ x += (x << 3u);
+ x ^= (x >> 11u);
+ x += (x << 15u);
+ return x;
+}
+
+uint hash(uvec2 v) { return hash(v.x ^ hash(v.y)); }
+uint hash(uvec3 v) { return hash(v.x ^ hash(v.y) ^ hash(v.z)); }
+uint hash(uvec4 v) {
+ return hash(v.x ^ hash(v.y) ^ hash(v.z) ^ hash(v.w));
+}
+
+float floatConstruct(uint m) {
+ const uint ieeeMantissa = 0x007FFFFFu;
+ const uint ieeeOne = 0x3F800000u;
+ m &= ieeeMantissa;
+ m |= ieeeOne;
+ float f = uintBitsToFloat(m);
+ return f - 1.0;
+}
+
+float randomBase(float x) {
+ return floatConstruct(hash(floatBitsToUint(x)));
+}
+float randomBase(vec2 v) {
+ return floatConstruct(hash(floatBitsToUint(v)));
+}
+float randomBase(vec3 v) {
+ return floatConstruct(hash(floatBitsToUint(v)));
+}
+float randomBase(vec4 v) {
+ return floatConstruct(hash(floatBitsToUint(v)));
+}
+
+float rand(float seed) {
+ return randomBase(vec3(gl_FragCoord.xy, seed));
+}
+
+const float F3 = 0.3333333;
+const float G3 = 0.1666667;
+
+float snoise(vec3 p) {
+ vec3 s = floor(p + dot(p, vec3(F3)));
+ vec3 x = p - s + dot(s, vec3(G3));
+ vec3 e = step(vec3(0.0), x - x.yzx);
+ vec3 i1 = e * (1.0 - e.zxy);
+ vec3 i2 = 1.0 - e.zxy * (1.0 - e);
+ vec3 x1 = x - i1 + G3;
+ vec3 x2 = x - i2 + 2.0 * G3;
+ vec3 x3 = x - 1.0 + 3.0 * G3;
+ vec4 w;
+ vec4 d;
+ w.x = dot(x, x);
+ w.y = dot(x1, x1);
+ w.z = dot(x2, x2);
+ w.w = dot(x3, x3);
+ w = max(0.6 - w, 0.0);
+ d.x = dot(random3(s), x);
+ d.y = dot(random3(s + i1), x1);
+ d.z = dot(random3(s + i2), x2);
+ d.w = dot(random3(s + 1.0), x3);
+ w *= w;
+ w *= w;
+ d *= w;
+ return dot(d, vec4(52.0));
+}
+
+float snoiseFractal(vec3 m) {
+ return 0.5333333 * snoise(m)
+ + 0.2666667 * snoise(2.0 * m)
+ + 0.1333333 * snoise(4.0 * m)
+ + 0.0666667 * snoise(8.0 * m);
+}
+`;
+
+const HALFTONE_TRANSMISSION_PARS_FRAGMENT = String.raw`
+#ifdef USE_TRANSMISSION
+ uniform float _transmission;
+ uniform float thickness;
+ uniform float attenuationDistance;
+ uniform vec3 attenuationColor;
+ uniform sampler2D refractionEnvMap;
+ uniform float useEnvMapRefraction;
+ #ifdef USE_TRANSMISSIONMAP
+ uniform sampler2D transmissionMap;
+ #endif
+ #ifdef USE_THICKNESSMAP
+ uniform sampler2D thicknessMap;
+ #endif
+ uniform vec2 transmissionSamplerSize;
+ uniform sampler2D transmissionSamplerMap;
+ uniform mat4 modelMatrix;
+ uniform mat4 projectionMatrix;
+ varying vec3 vWorldPosition;
+
+ vec3 getVolumeTransmissionRay(
+ const in vec3 n,
+ const in vec3 v,
+ const in float thicknessValue,
+ const in float ior,
+ const in mat4 modelMatrix
+ ) {
+ vec3 refractionVector = refract(-v, normalize(n), 1.0 / ior);
+ vec3 modelScale;
+ modelScale.x = length(vec3(modelMatrix[0].xyz));
+ modelScale.y = length(vec3(modelMatrix[1].xyz));
+ modelScale.z = length(vec3(modelMatrix[2].xyz));
+ return normalize(refractionVector) * thicknessValue * modelScale;
+ }
+
+ float applyIorToRoughness(
+ const in float roughnessValue,
+ const in float ior
+ ) {
+ return roughnessValue * clamp(ior * 2.0 - 2.0, 0.0, 1.0);
+ }
+
+ vec2 directionToEquirectUv(const in vec3 direction) {
+ vec3 dir = normalize(direction);
+ vec2 uv = vec2(
+ atan(dir.z, dir.x) * 0.15915494309189535 + 0.5,
+ asin(clamp(dir.y, -1.0, 1.0)) * 0.3183098861837907 + 0.5
+ );
+
+ return vec2(fract(uv.x), 1.0 - clamp(uv.y, 0.0, 1.0));
+ }
+
+ vec4 getTransmissionSample(
+ const in vec2 fragCoord,
+ const in vec3 transmissionDirection,
+ const in float roughnessValue,
+ const in float ior
+ ) {
+ if (useEnvMapRefraction > 0.5) {
+ return texture2D(
+ refractionEnvMap,
+ directionToEquirectUv(transmissionDirection)
+ );
+ }
+
+ float framebufferLod =
+ log2(transmissionSamplerSize.x) *
+ applyIorToRoughness(roughnessValue, ior);
+ return texture2D(buffer, fragCoord.xy);
+ }
+
+ vec3 applyVolumeAttenuation(
+ const in vec3 radiance,
+ const in float transmissionDistance,
+ const in vec3 attenuationColorValue,
+ const in float attenuationDistanceValue
+ ) {
+ if (isinf(attenuationDistanceValue)) {
+ return radiance;
+ }
+
+ vec3 attenuationCoefficient =
+ -log(attenuationColorValue) / attenuationDistanceValue;
+ vec3 transmittance =
+ exp(-attenuationCoefficient * transmissionDistance);
+
+ return transmittance * radiance;
+ }
+
+ vec4 getIBLVolumeRefraction(
+ const in vec3 n,
+ const in vec3 v,
+ const in float roughnessValue,
+ const in vec3 diffuseColor,
+ const in vec3 specularColor,
+ const in float specularF90,
+ const in vec3 position,
+ const in mat4 modelMatrix,
+ const in mat4 viewMatrix,
+ const in mat4 projMatrix,
+ const in float ior,
+ const in float thicknessValue,
+ const in vec3 attenuationColorValue,
+ const in float attenuationDistanceValue
+ ) {
+ vec3 transmissionRay = getVolumeTransmissionRay(
+ n,
+ v,
+ thicknessValue,
+ ior,
+ modelMatrix
+ );
+ vec3 refractedRayExit = position + transmissionRay;
+ vec4 ndcPos =
+ projMatrix * viewMatrix * vec4(refractedRayExit, 1.0);
+ vec2 refractionCoords = ndcPos.xy / ndcPos.w;
+ refractionCoords += 1.0;
+ refractionCoords /= 2.0;
+ vec3 transmissionDirection = normalize(transmissionRay);
+ vec4 transmittedLight = getTransmissionSample(
+ refractionCoords,
+ transmissionDirection,
+ roughnessValue,
+ ior
+ );
+ vec3 attenuatedColor = applyVolumeAttenuation(
+ transmittedLight.rgb,
+ length(transmissionRay),
+ attenuationColorValue,
+ attenuationDistanceValue
+ );
+ vec3 F = EnvironmentBRDF(
+ n,
+ v,
+ specularColor,
+ specularF90,
+ roughnessValue
+ );
+ return vec4(
+ (1.0 - F) * attenuatedColor * diffuseColor,
+ transmittedLight.a
+ );
+ }
+#endif
+`;
+
+const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = String.raw`
+material.transmission = _transmission;
+material.transmissionAlpha = 1.0;
+material.thickness = thickness;
+material.attenuationDistance = attenuationDistance;
+material.attenuationColor = attenuationColor;
+#ifdef USE_TRANSMISSIONMAP
+ material.transmission *= texture2D(transmissionMap, vUv).r;
+#endif
+#ifdef USE_THICKNESSMAP
+ material.thickness *= texture2D(thicknessMap, vUv).g;
+#endif
+
+vec3 pos = vWorldPosition;
+float runningSeed = 0.0;
+vec3 v = normalize(cameraPosition - pos);
+vec3 n = inverseTransformDirection(normal, viewMatrix);
+vec3 transmission = vec3(0.0);
+float transmissionR;
+float transmissionG;
+float transmissionB;
+float randomCoords = rand(runningSeed++);
+float thicknessSmear =
+ thickness * max(pow(roughnessFactor, 0.33), anisotropicBlur);
+vec3 distortionNormal = vec3(0.0);
+vec3 temporalOffset = vec3(time, -time, -time) * temporalDistortion;
+
+if (distortion > 0.0) {
+ distortionNormal = distortion * vec3(
+ snoiseFractal(vec3(pos * distortionScale + temporalOffset)),
+ snoiseFractal(vec3(pos.zxy * distortionScale - temporalOffset)),
+ snoiseFractal(vec3(pos.yxz * distortionScale + temporalOffset))
+ );
+}
+
+for (float i = 0.0; i < __SAMPLES__.0; i++) {
+ vec3 sampleNorm = normalize(
+ n +
+ roughnessFactor * roughnessFactor * 2.0 *
+ normalize(
+ vec3(
+ rand(runningSeed++) - 0.5,
+ rand(runningSeed++) - 0.5,
+ rand(runningSeed++) - 0.5
+ )
+ ) *
+ pow(rand(runningSeed++), 0.33) +
+ distortionNormal
+ );
+
+ transmissionR = getIBLVolumeRefraction(
+ sampleNorm,
+ v,
+ material.roughness,
+ material.diffuseColor,
+ material.specularColor,
+ material.specularF90,
+ pos,
+ modelMatrix,
+ viewMatrix,
+ projectionMatrix,
+ material.ior,
+ material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),
+ material.attenuationColor,
+ material.attenuationDistance
+ ).r;
+
+ transmissionG = getIBLVolumeRefraction(
+ sampleNorm,
+ v,
+ material.roughness,
+ material.diffuseColor,
+ material.specularColor,
+ material.specularF90,
+ pos,
+ modelMatrix,
+ viewMatrix,
+ projectionMatrix,
+ material.ior * (1.0 + chromaticAberration * (i + randomCoords) / float(__SAMPLES__)),
+ material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),
+ material.attenuationColor,
+ material.attenuationDistance
+ ).g;
+
+ transmissionB = getIBLVolumeRefraction(
+ sampleNorm,
+ v,
+ material.roughness,
+ material.diffuseColor,
+ material.specularColor,
+ material.specularF90,
+ pos,
+ modelMatrix,
+ viewMatrix,
+ projectionMatrix,
+ material.ior * (1.0 + 2.0 * chromaticAberration * (i + randomCoords) / float(__SAMPLES__)),
+ material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__),
+ material.attenuationColor,
+ material.attenuationDistance
+ ).b;
+
+ transmission.r += transmissionR;
+ transmission.g += transmissionG;
+ transmission.b += transmissionB;
+}
+
+transmission /= __SAMPLES__.0;
+totalDiffuse = mix(totalDiffuse, transmission.rgb, material.transmission);
+`;
+
+export const GEOMETRY_RUNTIME_SOURCE = String.raw`
+function makePolarShape(radiusFunction, segments = 320) {
+ const shape = new THREE.Shape();
+
+ for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
+ const angle = (segmentIndex / segments) * Math.PI * 2;
+ const radius = radiusFunction(angle);
+ const x = Math.cos(angle) * radius;
+ const y = Math.sin(angle) * radius;
+
+ if (segmentIndex === 0) {
+ shape.moveTo(x, y);
+ } else {
+ shape.lineTo(x, y);
+ }
+ }
+
+ return shape;
+}
+
+function makeReliefGeometry(shape, options = {}) {
+ const {
+ bevelSegments = 8,
+ bevelSize = 0.08,
+ bevelThickness = 0.1,
+ depth = 0.58,
+ waveDepth = 0.016,
+ waves = 8,
+ } = options;
+
+ const geometry = new THREE.ExtrudeGeometry(shape, {
+ depth,
+ steps: 2,
+ bevelEnabled: true,
+ bevelThickness,
+ bevelSize,
+ bevelSegments,
+ curveSegments: 96,
+ });
+
+ geometry.center();
+
+ const position = geometry.attributes.position;
+ let maxRadius = 0;
+
+ for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) {
+ maxRadius = Math.max(
+ maxRadius,
+ Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)),
+ );
+ }
+
+ const fullDepth = depth + bevelThickness * 2;
+
+ for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) {
+ const x = position.getX(vertexIndex);
+ const y = position.getY(vertexIndex);
+ const z = position.getZ(vertexIndex);
+ const radius = Math.hypot(x, y) / maxRadius;
+ const angle = Math.atan2(y, x);
+ const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5));
+ const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2));
+ const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2));
+ const wave =
+ Math.cos(angle * waves) *
+ Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) *
+ waveDepth;
+ const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave);
+
+ position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief);
+ }
+
+ position.needsUpdate = true;
+ geometry.computeVertexNormals();
+ geometry.computeBoundingBox();
+ geometry.computeBoundingSphere();
+
+ return geometry;
+}
+
+function mergeGeometries(geometries) {
+ if (geometries.length === 1) {
+ return geometries[0];
+ }
+
+ let totalVertices = 0;
+ let totalIndices = 0;
+ let hasUv = false;
+
+ const geometryInfos = geometries.map((geometry) => {
+ const position = geometry.attributes.position;
+ const normal = geometry.attributes.normal;
+ const uv = geometry.attributes.uv ?? null;
+ const index = geometry.index;
+ const indexCount = index ? index.count : position.count;
+
+ totalVertices += position.count;
+ totalIndices += indexCount;
+ hasUv = hasUv || uv !== null;
+
+ return {
+ index,
+ indexCount,
+ normal,
+ position,
+ uv,
+ vertexCount: position.count,
+ };
+ });
+
+ const positions = new Float32Array(totalVertices * 3);
+ const normals = new Float32Array(totalVertices * 3);
+ const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
+ const indices = new Uint32Array(totalIndices);
+
+ let vertexOffset = 0;
+ let indexOffset = 0;
+
+ for (const geometryInfo of geometryInfos) {
+ for (let vertexIndex = 0; vertexIndex < geometryInfo.vertexCount; vertexIndex += 1) {
+ const positionOffset = (vertexOffset + vertexIndex) * 3;
+ positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
+ positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
+ positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
+ normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
+ normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
+ normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
+
+ if (uvs !== null) {
+ const uvOffset = (vertexOffset + vertexIndex) * 2;
+ uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
+ uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
+ }
+ }
+
+ if (geometryInfo.index) {
+ for (let localIndex = 0; localIndex < geometryInfo.indexCount; localIndex += 1) {
+ indices[indexOffset + localIndex] =
+ geometryInfo.index.getX(localIndex) + vertexOffset;
+ }
+ } else {
+ for (let localIndex = 0; localIndex < geometryInfo.indexCount; localIndex += 1) {
+ indices[indexOffset + localIndex] = localIndex + vertexOffset;
+ }
+ }
+
+ vertexOffset += geometryInfo.vertexCount;
+ indexOffset += geometryInfo.indexCount;
+ }
+
+ const merged = new THREE.BufferGeometry();
+ merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+ merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
+
+ if (uvs !== null) {
+ merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
+ }
+
+ merged.setIndex(new THREE.BufferAttribute(indices, 1));
+
+ return merged;
+}
+
+function makeArrowTarget() {
+ const targetParts = [];
+ const arrowParts = [];
+ const baseRadius = 1.35;
+ const baseDepth = 0.32;
+ const bevel = 0.12;
+ const points = [];
+ const segments = 16;
+
+ points.push(new THREE.Vector2(0, -baseDepth / 2));
+ points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2));
+
+ for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
+ const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2);
+ points.push(
+ new THREE.Vector2(
+ baseRadius - bevel + Math.cos(angle) * bevel,
+ -baseDepth / 2 + bevel + Math.sin(angle) * bevel,
+ ),
+ );
+ }
+
+ points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel));
+
+ for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
+ const angle = (segmentIndex / segments) * (Math.PI / 2);
+ points.push(
+ new THREE.Vector2(
+ baseRadius - bevel + Math.cos(angle) * bevel,
+ baseDepth / 2 - bevel + Math.sin(angle) * bevel,
+ ),
+ );
+ }
+
+ points.push(new THREE.Vector2(0, baseDepth / 2));
+
+ const disc = new THREE.LatheGeometry(points, 64);
+ disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
+ targetParts.push(disc);
+
+ for (const radius of [0.45, 0.85, 1.22]) {
+ const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64);
+ ring.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04),
+ );
+ targetParts.push(ring);
+ }
+
+ const bump = new THREE.SphereGeometry(0.32, 32, 24, 0, Math.PI * 2, 0, Math.PI / 2);
+ bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2));
+ bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2));
+ targetParts.push(bump);
+
+ const shaftLength = 1.5;
+ const shaftRadius = 0.05;
+ const shaft = new THREE.CylinderGeometry(shaftRadius, shaftRadius, shaftLength, 10, 1);
+ shaft.applyMatrix4(new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0));
+ arrowParts.push(shaft);
+
+ const head = new THREE.ConeGeometry(0.12, 0.35, 10);
+ head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0));
+ arrowParts.push(head);
+
+ for (let finIndex = 0; finIndex < 3; finIndex += 1) {
+ const finShape = new THREE.Shape();
+ finShape.moveTo(0, 0);
+ finShape.lineTo(0.22, 0.25);
+ finShape.lineTo(0, 0.5);
+ finShape.lineTo(0, 0);
+
+ const finGeometry = new THREE.ExtrudeGeometry(finShape, {
+ depth: 0.012,
+ bevelEnabled: false,
+ });
+
+ finGeometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0.05, 0, -0.006));
+ finGeometry.applyMatrix4(
+ new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3),
+ );
+ finGeometry.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0),
+ );
+ arrowParts.push(finGeometry);
+ }
+
+ const nock = new THREE.SphereGeometry(0.065, 8, 8);
+ nock.applyMatrix4(new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0));
+ arrowParts.push(nock);
+
+ const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15);
+ const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5);
+ const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12);
+
+ for (const geometry of arrowParts) {
+ geometry.applyMatrix4(aim);
+ geometry.applyMatrix4(tilt);
+ geometry.applyMatrix4(shift);
+ }
+
+ const merged = mergeGeometries([...targetParts, ...arrowParts]);
+ merged.computeVertexNormals();
+ merged.computeBoundingSphere();
+
+ return merged;
+}
+
+function makeDollarCoin() {
+ const parts = [];
+ const baseRadius = 1.3;
+ const baseDepth = 0.45;
+ const bevel = 0.18;
+ const points = [];
+ const segments = 20;
+
+ points.push(new THREE.Vector2(0, -baseDepth / 2));
+ points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2));
+
+ for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
+ const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI;
+ points.push(
+ new THREE.Vector2(
+ baseRadius - bevel + Math.cos(angle) * bevel,
+ Math.sin(angle) * (baseDepth / 2),
+ ),
+ );
+ }
+
+ points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2));
+ points.push(new THREE.Vector2(0, baseDepth / 2));
+
+ const disc = new THREE.LatheGeometry(points, 64);
+ disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
+ parts.push(disc);
+
+ const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64);
+ frontRim.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01),
+ );
+ parts.push(frontRim);
+
+ const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64);
+ backRim.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)),
+ );
+ parts.push(backRim);
+
+ const createDollarSign = () => {
+ const geometries = [];
+ const tubeRadius = 0.1;
+ const curveRadius = 0.28;
+ const verticalOffset = 0.22;
+
+ const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12);
+ geometries.push(bar);
+
+ const topArc = new THREE.TorusGeometry(curveRadius, tubeRadius, 16, 32, Math.PI);
+ topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
+ topArc.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0),
+ );
+ geometries.push(topArc);
+
+ const bottomArc = new THREE.TorusGeometry(curveRadius, tubeRadius, 16, 32, Math.PI);
+ bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2));
+ bottomArc.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0),
+ );
+ geometries.push(bottomArc);
+
+ const topSerif = new THREE.CylinderGeometry(tubeRadius, tubeRadius, 0.22, 12);
+ topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
+ topSerif.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0.16, verticalOffset + curveRadius, 0),
+ );
+ geometries.push(topSerif);
+
+ const bottomSerif = new THREE.CylinderGeometry(tubeRadius, tubeRadius, 0.22, 12);
+ bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
+ bottomSerif.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(-0.16, -verticalOffset - curveRadius, 0),
+ );
+ geometries.push(bottomSerif);
+
+ const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2);
+ const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1);
+ const diagonal = new THREE.CylinderGeometry(tubeRadius, tubeRadius, diagonalLength + 0.12, 12);
+ diagonal.applyMatrix4(
+ new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2),
+ );
+ geometries.push(diagonal);
+
+ return geometries;
+ };
+
+ for (const geometry of createDollarSign()) {
+ geometry.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01),
+ );
+ parts.push(geometry);
+ }
+
+ for (const geometry of createDollarSign()) {
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI));
+ geometry.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)),
+ );
+ parts.push(geometry);
+ }
+
+ const merged = mergeGeometries(parts);
+ merged.computeVertexNormals();
+ merged.computeBoundingSphere();
+
+ return merged;
+}
+
+function createBuiltinGeometry(shapeKey) {
+ switch (shapeKey) {
+ case 'torusKnot':
+ return new THREE.TorusKnotGeometry(1, 0.35, 200, 32);
+ case 'sphere':
+ return new THREE.SphereGeometry(1.4, 64, 64);
+ case 'torus':
+ return new THREE.TorusGeometry(1, 0.45, 64, 100);
+ case 'icosahedron':
+ return new THREE.IcosahedronGeometry(1.4, 4);
+ case 'box':
+ return new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6);
+ case 'cone':
+ return new THREE.ConeGeometry(1.2, 2.4, 64, 10);
+ case 'cylinder':
+ return new THREE.CylinderGeometry(1, 1, 2.3, 64, 10);
+ case 'octahedron':
+ return new THREE.OctahedronGeometry(1.5, 2);
+ case 'dodecahedron':
+ return new THREE.DodecahedronGeometry(1.35, 1);
+ case 'tetrahedron':
+ return new THREE.TetrahedronGeometry(1.7, 1);
+ case 'sunCoin':
+ return makeReliefGeometry(
+ makePolarShape(
+ (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5),
+ ),
+ { depth: 0.62, waves: 12, waveDepth: 0.018 },
+ );
+ case 'lotusCoin':
+ return makeReliefGeometry(
+ makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)),
+ { depth: 0.64, waves: 8, waveDepth: 0.014 },
+ );
+ case 'arrowTarget':
+ return makeArrowTarget();
+ case 'dollarCoin':
+ return makeDollarCoin();
+ default:
+ return new THREE.TorusKnotGeometry(1, 0.35, 200, 32);
+ }
+}
+`;
+
+const IMPORTED_RUNTIME_SHARED_SOURCE = String.raw`
+const EMPTY_TEXTURE_DATA_URL =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
+
+function createLoadingManager() {
+ const loadingManager = new THREE.LoadingManager();
+ loadingManager.setURLModifier((url) =>
+ /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
+ );
+ return loadingManager;
+}
+
+function normalizeImportedGeometry(geometry) {
+ geometry.computeBoundingBox();
+
+ let boundingBox = geometry.boundingBox;
+ let center = new THREE.Vector3();
+ let size = new THREE.Vector3();
+
+ boundingBox?.getCenter(center);
+ boundingBox?.getSize(size);
+ geometry.translate(-center.x, -center.y, -center.z);
+
+ const dimensions = [size.x, size.y, size.z];
+ const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
+
+ if (thinnestAxis === 0) {
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
+ } else if (thinnestAxis === 1) {
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
+ }
+
+ geometry.computeBoundingBox();
+ geometry.computeBoundingSphere();
+
+ const radius = geometry.boundingSphere?.radius || 1;
+ const scale = 1.6 / radius;
+ geometry.scale(scale, scale, scale);
+
+ geometry.computeBoundingBox();
+ boundingBox = geometry.boundingBox;
+ center = new THREE.Vector3();
+ boundingBox?.getCenter(center);
+ geometry.translate(-center.x, -center.y, -center.z);
+
+ geometry.computeVertexNormals();
+ geometry.computeBoundingBox();
+ geometry.computeBoundingSphere();
+
+ return geometry;
+}
+
+function extractMergedGeometry(root, emptyMessage) {
+ root.updateMatrixWorld(true);
+ const geometries = [];
+
+ root.traverse((object) => {
+ if (!(object instanceof THREE.Mesh) || !object.geometry) {
+ return;
+ }
+
+ const geometry = object.geometry.clone();
+
+ if (!geometry.attributes.normal) {
+ geometry.computeVertexNormals();
+ }
+
+ geometry.applyMatrix4(object.matrixWorld);
+ geometries.push(geometry);
+ });
+
+ if (geometries.length === 0) {
+ throw new Error(emptyMessage);
+ }
+
+ return normalizeImportedGeometry(mergeGeometries(geometries));
+}
+`;
+
+const IMPORTED_FBX_RUNTIME_SOURCE = String.raw`
+
+function parseFbxGeometry(buffer, label) {
+ const originalWarn = console.warn;
+
+ console.warn = (...args) => {
+ if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) {
+ return;
+ }
+
+ originalWarn(...args);
+ };
+
+ try {
+ const root = new FBXLoader(createLoadingManager()).parse(buffer, '');
+ return extractMergedGeometry(root, label + ' did not contain any mesh geometry.');
+ } finally {
+ console.warn = originalWarn;
+ }
+}
+`;
+
+const IMPORTED_GLB_RUNTIME_SOURCE = String.raw`
+const DRACO_DECODER_PATH = ${JSON.stringify(DRACO_DECODER_PATH)};
+
+function parseGlbGeometry(buffer, label) {
+ return new Promise((resolve, reject) => {
+ const loadingManager = createLoadingManager();
+ const dracoLoader = new DRACOLoader(loadingManager);
+ dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
+
+ const loader = new GLTFLoader(loadingManager);
+ loader.setDRACOLoader(dracoLoader);
+
+ const cleanup = () => {
+ dracoLoader.dispose();
+ };
+
+ loader.parse(
+ buffer,
+ '',
+ (gltf) => {
+ try {
+ resolve(
+ extractMergedGeometry(
+ gltf.scene,
+ label + ' did not contain any mesh geometry.',
+ ),
+ );
+ } catch (error) {
+ reject(error);
+ } finally {
+ cleanup();
+ }
+ },
+ (error) => {
+ cleanup();
+ reject(error);
+ },
+ );
+ });
+}
+`;
+
+function createImportedRuntimeSource(loader: HalftoneGeometrySpec['loader']) {
+ const loaderSource =
+ loader === 'fbx'
+ ? IMPORTED_FBX_RUNTIME_SOURCE
+ : IMPORTED_GLB_RUNTIME_SOURCE;
+
+ return `${IMPORTED_RUNTIME_SHARED_SOURCE}
+${loaderSource}
+
+async function loadImportedGeometryFromUrl(modelUrl, label) {
+ const response = await fetch(modelUrl);
+
+ if (!response.ok) {
+ throw new Error('Unable to load ' + label + ' from ' + modelUrl + '.');
+ }
+
+ const buffer = await response.arrayBuffer();
+
+ ${
+ loader === 'fbx'
+ ? 'return parseFbxGeometry(buffer, label);'
+ : 'return parseGlbGeometry(buffer, label);'
+ }
+}
+`;
+}
+
+export function getExportedShapeLoader(
+ shape: ExportedShapeDescriptor,
+): HalftoneGeometrySpec['loader'] | null {
+ if (shape.loader) {
+ return shape.loader;
+ }
+
+ const filename = shape.filename?.toLowerCase() ?? '';
+
+ if (filename.endsWith('.fbx')) {
+ return 'fbx';
+ }
+
+ if (filename.endsWith('.glb')) {
+ return 'glb';
+ }
+
+ return null;
+}
+
+export function createImportedGeometryRuntimeSource(
+ shape: ExportedShapeDescriptor,
+) {
+ if (shape.kind !== 'imported') {
+ return '';
+ }
+
+ const loader = getExportedShapeLoader(shape) ?? 'glb';
+
+ return createImportedRuntimeSource(loader);
+}
+
+export const GLASS_MATERIAL_RUNTIME_SOURCE = String.raw`
+const GLASS_ENVIRONMENT_DATA_URL = ${JSON.stringify(GLASS_ENVIRONMENT_DATA_URL)};
+const GLASS_THICKNESS_TO_WORLD_UNITS = 1 / 320;
+const GLASS_ATTENUATION_DISTANCE_MIN = 0.12;
+const GLASS_ENVIRONMENT_INTENSITY_BASE = 0.18;
+const GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER = 0.12;
+const GLASS_ENVIRONMENT_ZOOM = 1.55;
+const GLASS_TRANSMISSION_BACKGROUND = new THREE.Color(0x030303);
+const MAX_TEXTURE_ANISOTROPY = 8;
+const HALFTONE_TRANSMISSION_SHADER_PREFIX = ${JSON.stringify(
+ HALFTONE_TRANSMISSION_SHADER_PREFIX,
+)};
+const HALFTONE_TRANSMISSION_PARS_FRAGMENT = ${JSON.stringify(
+ HALFTONE_TRANSMISSION_PARS_FRAGMENT,
+)};
+const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = ${JSON.stringify(
+ HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE,
+)};
+
+class HalftoneTransmissionMaterial extends THREE.MeshPhysicalMaterial {
+ constructor(samples = 10) {
+ super();
+
+ this.halftoneUniforms = {
+ chromaticAberration: { value: 0.05 },
+ transmission: { value: 0 },
+ _transmission: { value: 1 },
+ transmissionMap: { value: null },
+ refractionEnvMap: { value: null },
+ useEnvMapRefraction: { value: 0 },
+ roughness: { value: 0 },
+ thickness: { value: 0 },
+ thicknessMap: { value: null },
+ attenuationDistance: { value: Infinity },
+ attenuationColor: { value: new THREE.Color('white') },
+ anisotropicBlur: { value: 0.1 },
+ time: { value: 0 },
+ distortion: { value: 0 },
+ distortionScale: { value: 0.5 },
+ temporalDistortion: { value: 0 },
+ buffer: { value: null },
+ };
+
+ this.customProgramCacheKey = () => 'halftone-transmission-' + samples;
+
+ this.onBeforeCompile = (shader) => {
+ shader.uniforms = {
+ ...shader.uniforms,
+ ...this.halftoneUniforms,
+ };
+ shader.defines ??= {};
+
+ if (this.anisotropy > 0) {
+ shader.defines.USE_ANISOTROPY = '';
+ }
+
+ shader.defines.USE_TRANSMISSION = '';
+ shader.fragmentShader =
+ HALFTONE_TRANSMISSION_SHADER_PREFIX + shader.fragmentShader;
+ shader.fragmentShader = shader.fragmentShader.replace(
+ '#include ',
+ HALFTONE_TRANSMISSION_PARS_FRAGMENT,
+ );
+ shader.fragmentShader = shader.fragmentShader.replace(
+ '#include ',
+ HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE.replaceAll(
+ '__SAMPLES__',
+ String(samples),
+ ),
+ );
+ };
+
+ Object.keys(this.halftoneUniforms).forEach((key) => {
+ Object.defineProperty(this, key, {
+ configurable: true,
+ enumerable: true,
+ get: () => this.halftoneUniforms[key]?.value,
+ set: (value) => {
+ this.halftoneUniforms[key].value = value;
+ },
+ });
+ });
+ }
+}
+
+function setTextureSampling(texture, renderer) {
+ texture.generateMipmaps = true;
+ texture.magFilter = THREE.LinearFilter;
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
+ texture.anisotropy = Math.min(
+ renderer.capabilities.getMaxAnisotropy(),
+ MAX_TEXTURE_ANISOTROPY,
+ );
+}
+
+function disposeEnvironmentScene(scene) {
+ scene.traverse((object) => {
+ if (object.geometry) {
+ object.geometry.dispose();
+ }
+
+ if (Array.isArray(object.material)) {
+ object.material.forEach((material) => material.dispose());
+ return;
+ }
+
+ object.material?.dispose?.();
+ });
+}
+
+function createSolidEnvironmentTexture(renderer) {
+ const pmremGenerator = new THREE.PMREMGenerator(renderer);
+ const environmentTexture = pmremGenerator.fromScene(
+ new RoomEnvironment(),
+ 0.04,
+ ).texture;
+ pmremGenerator.dispose();
+
+ return environmentTexture;
+}
+
+function getTextureImageSize(texture) {
+ const image = texture.image;
+
+ return {
+ height:
+ image?.naturalHeight ?? image?.videoHeight ?? image?.height ?? undefined,
+ width:
+ image?.naturalWidth ?? image?.videoWidth ?? image?.width ?? undefined,
+ };
+}
+
+function createZoomedGlassTexture(sourceTexture, renderer, zoom) {
+ if (zoom <= 1) {
+ return sourceTexture;
+ }
+
+ const { width, height } = getTextureImageSize(sourceTexture);
+
+ if (!width || !height) {
+ return sourceTexture;
+ }
+
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+
+ const context = canvas.getContext('2d');
+
+ if (!context) {
+ return sourceTexture;
+ }
+
+ const cropWidth = width / zoom;
+ const cropHeight = height / zoom;
+ const sourceX = (width - cropWidth) / 2;
+ const sourceY = (height - cropHeight) / 2;
+
+ context.drawImage(
+ sourceTexture.image,
+ sourceX,
+ sourceY,
+ cropWidth,
+ cropHeight,
+ 0,
+ 0,
+ width,
+ height,
+ );
+
+ const zoomedTexture = new THREE.CanvasTexture(canvas);
+ zoomedTexture.colorSpace = sourceTexture.colorSpace;
+ zoomedTexture.wrapS = THREE.ClampToEdgeWrapping;
+ zoomedTexture.wrapT = THREE.ClampToEdgeWrapping;
+ setTextureSampling(zoomedTexture, renderer);
+ zoomedTexture.needsUpdate = true;
+
+ return zoomedTexture;
+}
+
+function createStudioGlassEnvironmentTexture(renderer, backdropTexture) {
+ const pmremGenerator = new THREE.PMREMGenerator(renderer);
+ const environmentTexture = backdropTexture
+ ? pmremGenerator.fromEquirectangular(backdropTexture).texture
+ : pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture;
+ pmremGenerator.dispose();
+
+ return environmentTexture;
+}
+
+function createFallbackGlassBackdropTexture(renderer) {
+ const texture = new THREE.DataTexture(
+ new Uint8Array([3, 3, 3, 255]),
+ 1,
+ 1,
+ THREE.RGBAFormat,
+ );
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.wrapS = THREE.ClampToEdgeWrapping;
+ texture.wrapT = THREE.ClampToEdgeWrapping;
+ texture.mapping = THREE.EquirectangularReflectionMapping;
+ setTextureSampling(texture, renderer);
+ texture.needsUpdate = true;
+
+ return texture;
+}
+
+function loadTexture(url, renderer, colorSpace) {
+ const loader = new THREE.TextureLoader();
+
+ return new Promise((resolve, reject) => {
+ loader.load(
+ url,
+ (texture) => {
+ texture.colorSpace = colorSpace;
+ setTextureSampling(texture, renderer);
+ resolve(texture);
+ },
+ undefined,
+ reject,
+ );
+ });
+}
+
+async function loadGlassEnvironmentTexture(renderer) {
+ const sourceBackgroundTexture = await loadTexture(
+ GLASS_ENVIRONMENT_DATA_URL,
+ renderer,
+ THREE.SRGBColorSpace,
+ );
+ const backgroundTexture = createZoomedGlassTexture(
+ sourceBackgroundTexture,
+ renderer,
+ GLASS_ENVIRONMENT_ZOOM,
+ );
+ if (backgroundTexture !== sourceBackgroundTexture) {
+ sourceBackgroundTexture.dispose();
+ }
+ backgroundTexture.mapping = THREE.EquirectangularReflectionMapping;
+ backgroundTexture.wrapS = THREE.ClampToEdgeWrapping;
+ backgroundTexture.wrapT = THREE.ClampToEdgeWrapping;
+ backgroundTexture.needsUpdate = true;
+ const environmentTexture = createStudioGlassEnvironmentTexture(
+ renderer,
+ backgroundTexture,
+ );
+
+ return {
+ backgroundTexture,
+ environmentTexture,
+ };
+}
+
+async function createHalftoneMaterialAssets(renderer) {
+ const solidEnvironmentTexture = createSolidEnvironmentTexture(renderer);
+
+ try {
+ const glassEnvironmentAssets = await loadGlassEnvironmentTexture(renderer);
+
+ return {
+ glassBackgroundTexture: glassEnvironmentAssets.backgroundTexture,
+ glassEnvironmentTexture: glassEnvironmentAssets.environmentTexture,
+ solidEnvironmentTexture,
+ };
+ } catch {
+ const fallbackGlassBackdropTexture =
+ createFallbackGlassBackdropTexture(renderer);
+ const fallbackGlassEnvironmentTexture =
+ createStudioGlassEnvironmentTexture(renderer);
+
+ return {
+ glassBackgroundTexture: fallbackGlassBackdropTexture,
+ glassEnvironmentTexture: fallbackGlassEnvironmentTexture,
+ solidEnvironmentTexture,
+ };
+ }
+}
+
+function createHalftoneMaterial() {
+ return new HalftoneTransmissionMaterial();
+}
+
+function applyHalftoneMaterialSettings(material, materialSettings, materialAssets) {
+ const isGlass = materialSettings.surface === 'glass';
+ const glassThickness =
+ materialSettings.thickness * GLASS_THICKNESS_TO_WORLD_UNITS;
+ const glassEnvironmentIntensity =
+ GLASS_ENVIRONMENT_INTENSITY_BASE +
+ materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER;
+ const glassAttenuationDistance = Math.max(
+ glassThickness * 4,
+ GLASS_ATTENUATION_DISTANCE_MIN,
+ );
+
+ material.color.set(isGlass ? '#ffffff' : materialSettings.color);
+ material.roughness = materialSettings.roughness;
+ material.metalness = materialSettings.metalness;
+ material.envMap = isGlass
+ ? materialAssets.glassEnvironmentTexture
+ : materialAssets.solidEnvironmentTexture;
+ material.envMapIntensity = isGlass
+ ? GLASS_ENVIRONMENT_INTENSITY_BASE +
+ materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER
+ : 0.25;
+ material.clearcoat = isGlass ? 1 : 0;
+ material.clearcoatRoughness = isGlass
+ ? Math.max(materialSettings.roughness * 0.25, 0.01)
+ : 0.08;
+ material.reflectivity = isGlass ? 0.98 : 0.5;
+ material.transmission = 0;
+ material._transmission = isGlass ? 1 : 0;
+ material.refractionEnvMap = isGlass ? materialAssets.glassBackgroundTexture : null;
+ material.useEnvMapRefraction = isGlass ? 1 : 0;
+ material.thickness = isGlass ? glassThickness : 0;
+ material.ior = isGlass ? materialSettings.refraction : 1.5;
+ material.buffer = null;
+ material.bumpMap = null;
+ material.bumpScale = 0;
+ material.roughnessMap = null;
+ material.side = THREE.FrontSide;
+ material.transparent = false;
+ material.opacity = 1;
+ material.depthWrite = true;
+ material.attenuationColor.set(isGlass ? materialSettings.color : 'white');
+ material.attenuationDistance = isGlass
+ ? glassAttenuationDistance
+ : Infinity;
+ material.anisotropicBlur = isGlass
+ ? THREE.MathUtils.lerp(0.03, 0.12, materialSettings.roughness)
+ : 0.1;
+ material.chromaticAberration = isGlass ? 0 : 0.05;
+ material.distortion = 0;
+ material.distortionScale = 0.5;
+ material.temporalDistortion = 0;
+ material.userData.halftoneIsGlass = isGlass;
+ material.userData.halftoneGlassBacksideThickness = isGlass
+ ? glassThickness * 2
+ : 0;
+ material.userData.halftoneGlassBacksideEnvIntensity = isGlass
+ ? glassEnvironmentIntensity * 2.8
+ : 0;
+ material.userData.halftoneUseEnvironmentRefraction = isGlass;
+ material.envMapIntensity = isGlass ? glassEnvironmentIntensity : 0.25;
+
+ material.needsUpdate = true;
+}
+
+function disposeHalftoneMaterialAssets(materialAssets) {
+ materialAssets.glassBackgroundTexture.dispose();
+
+ if (materialAssets.glassEnvironmentTexture !== materialAssets.glassBackgroundTexture) {
+ materialAssets.glassEnvironmentTexture.dispose();
+ }
+
+ materialAssets.solidEnvironmentTexture.dispose();
+}
+`;
diff --git a/packages/twenty-website-new/src/lib/halftone/utils/exporters.ts b/packages/twenty-website-new/src/lib/halftone/utils/exporters.ts
new file mode 100644
index 0000000000..1898182de2
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/halftone/utils/exporters.ts
@@ -0,0 +1,1438 @@
+import { normalizeExportComponentName } from '@/lib/halftone/utils/export-names';
+import { VIRTUAL_RENDER_HEIGHT } from '@/lib/halftone/utils/footprint';
+import { HALFTONE_FOOTPRINT_RUNTIME_SOURCE } from '@/lib/halftone/generated/footprint-runtime-source';
+import {
+ IMAGE_HOVER_FADE_IN,
+ IMAGE_HOVER_FADE_OUT,
+ IMAGE_POINTER_FOLLOW,
+ IMAGE_POINTER_VELOCITY_DAMPING,
+} from '@/lib/halftone/constants/halftone-image-constants';
+import {
+ RESET_INTERACTION_STATE_RUNTIME_SOURCE,
+ SPRING_STEP_RUNTIME_SOURCE,
+} from '@/lib/halftone/utils/interaction-state';
+import type {
+ HalftoneExportPose,
+ HalftoneGeometrySpec,
+ HalftoneStudioSettings,
+} from '@/lib/halftone/utils/state';
+
+import {
+ emitBlurMaterials,
+ emitBlurPasses,
+ emitCleanup,
+ emitHalftoneMaterial,
+ emitPostScenes,
+ emitRendererSetup,
+ emitSizingHelpers,
+} from './exporter-mount-fragments';
+import {
+ blurFragmentShader,
+ createImportedGeometryRuntimeSource,
+ GEOMETRY_RUNTIME_SOURCE,
+ getExportedShapeLoader,
+ GLASS_MATERIAL_RUNTIME_SOURCE,
+ halftoneFragmentShader,
+ imagePassthroughFragmentShader,
+ passThroughVertexShader,
+} from './exporter-shaders';
+import {
+ normalizeExportPose,
+ normalizePreviewDistance,
+} from './exporter-parsing';
+import {
+ DEFAULT_REACT_EXPORT_SETTINGS,
+ type ExportedShapeDescriptor,
+ type ReactExportOptions,
+ type ReactExportSettings,
+} from '../types/exporter-types';
+
+export type {
+ ExportedShapeDescriptor,
+ ParsedExportedPreset,
+ ReactExportOptions,
+ ReactExportSettings,
+} from '../types/exporter-types';
+export { DEFAULT_REACT_EXPORT_SETTINGS } from '../types/exporter-types';
+export {
+ deriveExportComponentName,
+ parseExportedPreset,
+} from './exporter-parsing';
+
+function toIllustrationRegistryKey(componentName: string) {
+ return componentName.charAt(0).toLowerCase() + componentName.slice(1);
+}
+
+function toPublicAssetDestination(assetUrl: string) {
+ return assetUrl.startsWith('/') ? `public${assetUrl}` : assetUrl;
+}
+
+function normalizeReactExportSettings(
+ exportSettings?: Partial,
+): ReactExportSettings {
+ return {
+ ...DEFAULT_REACT_EXPORT_SETTINGS,
+ ...exportSettings,
+ };
+}
+
+function getReactImportBlock(
+ exportSettings: ReactExportSettings,
+ isImageMode: boolean,
+ shape: ExportedShapeDescriptor,
+) {
+ const imports = new Set();
+
+ imports.add("import { useEffect, useRef, type CSSProperties } from 'react';");
+ imports.add("import * as THREE from 'three';");
+
+ if (exportSettings.includeStyledMount) {
+ imports.add("import { styled } from '@linaria/react';");
+ }
+
+ if (!isImageMode) {
+ imports.add(
+ "import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';",
+ );
+ }
+
+ const loader = getExportedShapeLoader(shape);
+
+ if (shape.kind === 'imported' && loader === 'fbx') {
+ imports.add(
+ "import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';",
+ );
+ }
+
+ if (shape.kind === 'imported' && loader === 'glb') {
+ imports.add(
+ "import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';",
+ );
+ imports.add(
+ "import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';",
+ );
+ }
+
+ return Array.from(imports).join('\n');
+}
+
+function getStandaloneThreeImports(
+ isImageMode: boolean,
+ shape: ExportedShapeDescriptor,
+) {
+ if (isImageMode) {
+ return `import * as THREE from 'three';`;
+ }
+
+ const imports = [
+ `import * as THREE from 'three';`,
+ `import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';`,
+ ];
+
+ const loader = getExportedShapeLoader(shape);
+
+ if (shape.kind === 'imported' && loader === 'fbx') {
+ imports.push(
+ `import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';`,
+ );
+ }
+
+ if (shape.kind === 'imported' && loader === 'glb') {
+ imports.push(
+ `import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';`,
+ `import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';`,
+ );
+ }
+
+ return imports.join('\n ');
+}
+
+function getTwentyReactHeaderComment(
+ componentName: string,
+ registryKey: string,
+ assetUrl?: string,
+) {
+ const lines = [
+ `// Suggested component destination: src/illustrations/${componentName}.tsx`,
+ ];
+
+ if (assetUrl) {
+ lines.push(
+ `// Suggested public asset destination: ${toPublicAssetDestination(assetUrl)}`,
+ );
+ }
+
+ lines.push(
+ `// illustrations-registry.tsx: import { ${componentName} } from './${componentName}';`,
+ `// illustrations-registry.tsx: ${registryKey}: ${componentName},`,
+ );
+
+ return `${lines.join('\n')}\n`;
+}
+
+function createShapeDescriptor(
+ shape: HalftoneGeometrySpec | undefined,
+ settings: HalftoneStudioSettings,
+ importedFile?: File,
+ modelFilenameOverride?: string,
+): ExportedShapeDescriptor {
+ if (!shape) {
+ return {
+ filename: null,
+ key: settings.shapeKey,
+ kind: 'builtin',
+ label: settings.shapeKey,
+ loader: null,
+ };
+ }
+
+ const effectiveImportedFilename =
+ modelFilenameOverride ?? importedFile?.name ?? shape.filename ?? null;
+
+ return {
+ filename:
+ shape.kind === 'imported'
+ ? effectiveImportedFilename
+ : (shape.filename ?? null),
+ key: shape.key,
+ kind: shape.kind,
+ label:
+ shape.kind === 'imported'
+ ? (effectiveImportedFilename ?? shape.label)
+ : shape.label,
+ loader: shape.loader ?? null,
+ };
+}
+
+function getModelMimeType(
+ file: File,
+ loader: HalftoneGeometrySpec['loader'] | null,
+) {
+ if (file.type) {
+ return file.type;
+ }
+
+ if (loader === 'glb' || file.name.toLowerCase().endsWith('.glb')) {
+ return 'model/gltf-binary';
+ }
+
+ if (loader === 'fbx' || file.name.toLowerCase().endsWith('.fbx')) {
+ return 'application/octet-stream';
+ }
+
+ return 'application/octet-stream';
+}
+
+async function fileToDataUrl(
+ file: File,
+ loader: HalftoneGeometrySpec['loader'] | null,
+) {
+ const buffer = await file.arrayBuffer();
+ const blob = new Blob([buffer], {
+ type: getModelMimeType(file, loader),
+ });
+
+ return await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+
+ reader.onerror = () => {
+ reject(reader.error ?? new Error(`Unable to read ${file.name}.`));
+ };
+ reader.onload = () => {
+ if (typeof reader.result !== 'string') {
+ reject(new Error(`Unable to encode ${file.name}.`));
+ return;
+ }
+
+ resolve(reader.result);
+ };
+
+ reader.readAsDataURL(blob);
+ });
+}
+
+function serializeRuntimeSource(
+ settings: HalftoneStudioSettings,
+ shape: ExportedShapeDescriptor,
+ initialPose: HalftoneExportPose,
+ previewDistance: number | undefined,
+) {
+ const isImageMode = settings.sourceMode === 'image';
+ const normalizedPreviewDistance = normalizePreviewDistance(previewDistance);
+
+ return `
+const settings = ${JSON.stringify(settings, null, 2)};
+const shape = ${JSON.stringify(shape, null, 2)};
+const initialPose = ${JSON.stringify(initialPose, null, 2)};
+const previewDistance = ${JSON.stringify(normalizedPreviewDistance)};
+const VIRTUAL_RENDER_HEIGHT = ${VIRTUAL_RENDER_HEIGHT};
+const passThroughVertexShader = ${JSON.stringify(passThroughVertexShader)};
+const blurFragmentShader = ${JSON.stringify(blurFragmentShader)};
+const halftoneFragmentShader = ${JSON.stringify(halftoneFragmentShader)};
+${isImageMode ? `const imagePassthroughFragmentShader = ${JSON.stringify(imagePassthroughFragmentShader)};` : ''}
+
+${HALFTONE_FOOTPRINT_RUNTIME_SOURCE}
+
+${isImageMode ? '' : GEOMETRY_RUNTIME_SOURCE}
+
+${isImageMode ? '' : createImportedGeometryRuntimeSource(shape)}
+
+${isImageMode ? '' : GLASS_MATERIAL_RUNTIME_SOURCE}
+
+function createRenderTarget(width, height) {
+ return new THREE.WebGLRenderTarget(width, height, {
+ minFilter: THREE.LinearFilter,
+ magFilter: THREE.LinearFilter,
+ format: THREE.RGBAFormat,
+ });
+}
+
+function createInteractionState() {
+ return {
+ autoElapsed: initialPose.autoElapsed,
+ activePointerId: null,
+ dragging: false,
+ hoverStrength: 0,
+ mouseX: 0.5,
+ mouseY: 0.5,
+ pointerInside: false,
+ pointerVelocityX: 0,
+ pointerVelocityY: 0,
+ pointerX: 0,
+ pointerY: 0,
+ rotateElapsed: initialPose.rotateElapsed,
+ rotationX: initialPose.rotationX,
+ rotationVelocityX: 0,
+ rotationY: initialPose.rotationY,
+ rotationVelocityY: 0,
+ rotationZ: initialPose.rotationZ,
+ rotationVelocityZ: 0,
+ smoothedMouseX: 0.5,
+ smoothedMouseY: 0.5,
+ targetRotationX: initialPose.targetRotationX,
+ targetRotationY: initialPose.targetRotationY,
+ velocityX: 0,
+ velocityY: 0,
+ };
+}
+
+${
+ isImageMode
+ ? ''
+ : `
+function setPrimaryLightPosition(light, angleDegrees, height) {
+ const lightAngle = (angleDegrees * Math.PI) / 180;
+ light.position.set(Math.cos(lightAngle) * 5, height, Math.sin(lightAngle) * 5);
+}
+
+${SPRING_STEP_RUNTIME_SOURCE}
+
+${RESET_INTERACTION_STATE_RUNTIME_SOURCE}
+
+async function createGeometry(modelUrl) {
+ if (shape.kind === 'imported') {
+ if (!modelUrl) {
+ throw new Error('No model URL was provided for ' + shape.label + '.');
+ }
+
+ return loadImportedGeometryFromUrl(modelUrl, shape.label);
+ }
+
+ return createBuiltinGeometry(shape.key);
+}
+`
+}
+`;
+}
+
+function createMountScript() {
+ return `
+async function mountHalftoneCanvas(options) {
+ const {
+ container,
+ modelUrl,
+ onError,
+ } = options;
+
+${emitSizingHelpers()}
+
+ let geometry;
+
+ try {
+ geometry = await createGeometry(modelUrl);
+ } catch (error) {
+ onError?.(error);
+ return () => {};
+ }
+
+${emitRendererSetup("settings.animation.followDragEnabled ? 'grab' : 'default'")}
+
+ const materialAssets = await createHalftoneMaterialAssets(renderer);
+
+ const scene3d = new THREE.Scene();
+ scene3d.background = null;
+
+ const baseCameraDistance = previewDistance;
+ const camera = new THREE.PerspectiveCamera(45, getWidth() / getHeight(), 0.1, 100);
+ camera.position.z = baseCameraDistance;
+
+ const primaryLight = new THREE.DirectionalLight(0xffffff, settings.lighting.intensity);
+ setPrimaryLightPosition(
+ primaryLight,
+ settings.lighting.angleDegrees,
+ settings.lighting.height,
+ );
+ scene3d.add(primaryLight);
+
+ const fillLight = new THREE.DirectionalLight(
+ 0xffffff,
+ settings.lighting.fillIntensity,
+ );
+ fillLight.position.set(-3, -1, 1);
+ scene3d.add(fillLight);
+
+ const ambientLight = new THREE.AmbientLight(
+ 0xffffff,
+ settings.lighting.ambientIntensity,
+ );
+ scene3d.add(ambientLight);
+
+ const material = createHalftoneMaterial();
+ applyHalftoneMaterialSettings(material, settings.material, materialAssets);
+
+ const mesh = new THREE.Mesh(geometry, material);
+ scene3d.add(mesh);
+
+ const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
+ const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
+ const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
+ const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
+ const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
+
+${emitBlurMaterials()}
+${emitHalftoneMaterial({ cropToBounds: 0, hoverHalftoneRadius: '0.2', hoverLightRadius: '0.2', waveSpeed: '1' })}
+${emitPostScenes()}
+
+ const updateViewportUniforms = (
+ logicalWidth,
+ logicalHeight,
+ effectWidth,
+ effectHeight,
+ ) => {
+ blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
+ blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
+ halftoneMaterial.uniforms.effectResolution.value.set(
+ effectWidth,
+ effectHeight,
+ );
+ halftoneMaterial.uniforms.logicalResolution.value.set(
+ logicalWidth,
+ logicalHeight,
+ );
+ };
+
+ const getHalftoneScale = (viewportWidth, viewportHeight, lookAtTarget) => {
+ if (!mesh.geometry.boundingBox) {
+ mesh.geometry.computeBoundingBox();
+ }
+
+ if (!mesh.geometry.boundingBox) {
+ return 1;
+ }
+
+ mesh.updateMatrixWorld();
+ camera.updateMatrixWorld();
+
+ return getMeshFootprintScale({
+ camera,
+ localBounds: mesh.geometry.boundingBox,
+ lookAtTarget,
+ meshMatrixWorld: mesh.matrixWorld,
+ viewportHeight,
+ viewportWidth,
+ });
+ };
+
+ const interaction = createInteractionState();
+ const autoRotateEnabled = settings.animation.autoRotateEnabled;
+ const followHoverEnabled = settings.animation.followHoverEnabled;
+ const followDragEnabled = settings.animation.followDragEnabled;
+ const rotateEnabled = settings.animation.rotateEnabled;
+
+ const syncSize = () => {
+ const width = getWidth();
+ const height = getHeight();
+ const virtualWidth = getVirtualWidth();
+ const virtualHeight = getVirtualHeight();
+
+ renderer.setSize(virtualWidth, virtualHeight, false);
+ camera.aspect = width / height;
+ camera.updateProjectionMatrix();
+ sceneTarget.setSize(virtualWidth, virtualHeight);
+ blurTargetA.setSize(virtualWidth, virtualHeight);
+ blurTargetB.setSize(virtualWidth, virtualHeight);
+ updateViewportUniforms(
+ virtualWidth,
+ virtualHeight,
+ virtualWidth,
+ virtualHeight,
+ );
+ };
+
+ const resizeObserver = new ResizeObserver(syncSize);
+ resizeObserver.observe(container);
+
+ const updatePointerPosition = (event) => {
+ const rect = canvas.getBoundingClientRect();
+ const width = Math.max(rect.width, 1);
+ const height = Math.max(rect.height, 1);
+
+ interaction.mouseX = THREE.MathUtils.clamp(
+ (event.clientX - rect.left) / width,
+ 0,
+ 1,
+ );
+ interaction.mouseY = THREE.MathUtils.clamp(
+ (event.clientY - rect.top) / height,
+ 0,
+ 1,
+ );
+ };
+
+ const handlePointerDown = (event) => {
+ updatePointerPosition(event);
+ if (!followDragEnabled) {
+ return;
+ }
+
+ interaction.dragging = true;
+ interaction.pointerX = event.clientX;
+ interaction.pointerY = event.clientY;
+ interaction.velocityX = 0;
+ interaction.velocityY = 0;
+ canvas.style.cursor = 'grabbing';
+ };
+
+ const handlePointerMove = (event) => {
+ updatePointerPosition(event);
+ };
+
+ const handleWindowPointerMove = (event) => {
+ updatePointerPosition(event);
+
+ if (!interaction.dragging || !followDragEnabled) {
+ return;
+ }
+
+ const deltaX = (event.clientX - interaction.pointerX) * settings.animation.dragSens;
+ const deltaY = (event.clientY - interaction.pointerY) * settings.animation.dragSens;
+ interaction.velocityX = deltaY;
+ interaction.velocityY = deltaX;
+ interaction.targetRotationY += deltaX;
+ interaction.targetRotationX += deltaY;
+ interaction.pointerX = event.clientX;
+ interaction.pointerY = event.clientY;
+ };
+
+ const handlePointerLeave = () => {
+ if (interaction.dragging) {
+ return;
+ }
+
+ interaction.mouseX = 0.5;
+ interaction.mouseY = 0.5;
+ };
+
+ const handlePointerUp = () => {
+ interaction.dragging = false;
+ canvas.style.cursor = followDragEnabled ? 'grab' : 'default';
+
+ if (!settings.animation.springReturnEnabled) {
+ return;
+ }
+
+ const springImpulse = Math.max(settings.animation.springStrength * 10, 1.2);
+ interaction.rotationVelocityX += interaction.velocityX * springImpulse;
+ interaction.rotationVelocityY += interaction.velocityY * springImpulse;
+ interaction.rotationVelocityZ += interaction.velocityY * springImpulse * 0.12;
+ interaction.targetRotationX = 0;
+ interaction.targetRotationY = 0;
+ interaction.velocityX = 0;
+ interaction.velocityY = 0;
+ };
+
+ const handlePointerCancel = () => {
+ interaction.dragging = false;
+ interaction.velocityX = 0;
+ interaction.velocityY = 0;
+ canvas.style.cursor = followDragEnabled ? 'grab' : 'default';
+ handlePointerLeave();
+ };
+
+ const handleWindowBlur = () => {
+ handlePointerUp();
+ handlePointerLeave();
+ };
+
+ canvas.addEventListener('pointermove', handlePointerMove);
+ canvas.addEventListener('pointerleave', handlePointerLeave);
+ canvas.addEventListener('pointercancel', handlePointerCancel);
+ window.addEventListener('pointerup', handlePointerUp);
+ window.addEventListener('pointermove', handleWindowPointerMove);
+ window.addEventListener('blur', handleWindowBlur);
+ canvas.addEventListener('pointerdown', handlePointerDown);
+
+ const clock = new THREE.Timer();
+ clock.connect(document);
+ let animationFrameId = 0;
+
+ const renderFrame = (timestamp) => {
+ // boundary-allow-next-line:no-raw-animation-frame -- emitted into standalone HTML export
+ animationFrameId = window.requestAnimationFrame(renderFrame);
+ clock.update(timestamp);
+
+ const delta = 1 / 60;
+ const elapsedTime = initialPose.timeElapsed + clock.getElapsed();
+ halftoneMaterial.uniforms.time.value = elapsedTime;
+
+ let baseRotationX = initialPose.rotationX;
+ let baseRotationY = initialPose.rotationY;
+ let baseRotationZ = initialPose.rotationZ;
+ let meshOffsetY = 0;
+ let meshScale = 1;
+ let lightAngle = settings.lighting.angleDegrees;
+ let lightHeight = settings.lighting.height;
+
+ if (autoRotateEnabled) {
+ interaction.autoElapsed += delta;
+ baseRotationY += interaction.autoElapsed * settings.animation.autoSpeed;
+ baseRotationX += Math.sin(interaction.autoElapsed * 0.2) * settings.animation.autoWobble;
+ }
+
+ if (settings.animation.floatEnabled) {
+ const floatPhase = elapsedTime * settings.animation.floatSpeed;
+ const driftAmount = (settings.animation.driftAmount * Math.PI) / 180;
+
+ meshOffsetY += Math.sin(floatPhase) * settings.animation.floatAmplitude;
+ baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45;
+ baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3;
+ }
+
+ if (settings.animation.breatheEnabled) {
+ meshScale *=
+ 1 +
+ Math.sin(elapsedTime * settings.animation.breatheSpeed) *
+ settings.animation.breatheAmount;
+ }
+
+ if (rotateEnabled) {
+ interaction.rotateElapsed += delta;
+ const rotateProgress = settings.animation.rotatePingPong
+ ? Math.sin(interaction.rotateElapsed * settings.animation.rotateSpeed) * Math.PI
+ : interaction.rotateElapsed * settings.animation.rotateSpeed;
+
+ if (settings.animation.rotatePreset === 'axis') {
+ const axisDirection =
+ settings.animation.rotateAxis.startsWith('-') ? -1 : 1;
+ const axisProgress = rotateProgress * axisDirection;
+
+ if (
+ settings.animation.rotateAxis === 'x' ||
+ settings.animation.rotateAxis === 'xy' ||
+ settings.animation.rotateAxis === '-x' ||
+ settings.animation.rotateAxis === '-xy'
+ ) {
+ baseRotationX += axisProgress;
+ }
+
+ if (
+ settings.animation.rotateAxis === 'y' ||
+ settings.animation.rotateAxis === 'xy' ||
+ settings.animation.rotateAxis === '-y' ||
+ settings.animation.rotateAxis === '-xy'
+ ) {
+ baseRotationY += axisProgress;
+ }
+
+ if (
+ settings.animation.rotateAxis === 'z' ||
+ settings.animation.rotateAxis === '-z'
+ ) {
+ baseRotationZ += axisProgress;
+ }
+ } else if (settings.animation.rotatePreset === 'lissajous') {
+ baseRotationX += Math.sin(rotateProgress * 0.85) * 0.65;
+ baseRotationY += Math.sin(rotateProgress * 1.35 + 0.8) * 1.05;
+ baseRotationZ += Math.sin(rotateProgress * 0.55 + 1.6) * 0.32;
+ } else if (settings.animation.rotatePreset === 'orbit') {
+ baseRotationX += Math.sin(rotateProgress * 0.75) * 0.42;
+ baseRotationY += Math.cos(rotateProgress) * 1.2;
+ baseRotationZ += Math.sin(rotateProgress * 1.25) * 0.24;
+ } else if (settings.animation.rotatePreset === 'tumble') {
+ baseRotationX += rotateProgress * 0.55;
+ baseRotationY += Math.sin(rotateProgress * 0.8) * 0.9;
+ baseRotationZ += Math.cos(rotateProgress * 1.1) * 0.38;
+ }
+ }
+
+ if (settings.animation.lightSweepEnabled) {
+ const lightPhase = elapsedTime * settings.animation.lightSweepSpeed;
+ lightAngle += Math.sin(lightPhase) * settings.animation.lightSweepRange;
+ lightHeight +=
+ Math.cos(lightPhase * 0.85) *
+ settings.animation.lightSweepHeightRange;
+ }
+
+ let targetX = baseRotationX;
+ let targetY = baseRotationY;
+ let easing = 0.12;
+
+ if (followHoverEnabled) {
+ const rangeRadians = (settings.animation.hoverRange * Math.PI) / 180;
+
+ if (
+ settings.animation.hoverReturn ||
+ interaction.mouseX !== 0.5 ||
+ interaction.mouseY !== 0.5
+ ) {
+ targetX += (interaction.mouseY - 0.5) * rangeRadians;
+ targetY += (interaction.mouseX - 0.5) * rangeRadians;
+ }
+
+ easing = settings.animation.hoverEase;
+ }
+
+ if (followDragEnabled) {
+ if (!interaction.dragging && settings.animation.dragMomentum) {
+ interaction.targetRotationX += interaction.velocityX;
+ interaction.targetRotationY += interaction.velocityY;
+ interaction.velocityX *= 1 - settings.animation.dragFriction;
+ interaction.velocityY *= 1 - settings.animation.dragFriction;
+ }
+
+ targetX += interaction.targetRotationX;
+ targetY += interaction.targetRotationY;
+ easing = settings.animation.dragFriction;
+ }
+
+ if (autoRotateEnabled && !followHoverEnabled && !followDragEnabled) {
+ targetX = baseRotationX + interaction.targetRotationX;
+ targetY = baseRotationY + interaction.targetRotationY;
+
+ if (interaction.dragging) {
+ targetX = interaction.targetRotationX;
+ targetY = interaction.targetRotationY;
+ }
+
+ easing = 0.08;
+ }
+
+ if (settings.animation.springReturnEnabled) {
+ const springX = applySpringStep(
+ interaction.rotationX,
+ targetX,
+ interaction.rotationVelocityX,
+ settings.animation.springStrength,
+ settings.animation.springDamping,
+ );
+ const springY = applySpringStep(
+ interaction.rotationY,
+ targetY,
+ interaction.rotationVelocityY,
+ settings.animation.springStrength,
+ settings.animation.springDamping,
+ );
+ const springZ = applySpringStep(
+ interaction.rotationZ,
+ baseRotationZ,
+ interaction.rotationVelocityZ,
+ settings.animation.springStrength,
+ settings.animation.springDamping,
+ );
+
+ interaction.rotationX = springX.value;
+ interaction.rotationY = springY.value;
+ interaction.rotationZ = springZ.value;
+ interaction.rotationVelocityX = springX.velocity;
+ interaction.rotationVelocityY = springY.velocity;
+ interaction.rotationVelocityZ = springZ.velocity;
+ } else {
+ interaction.rotationX += (targetX - interaction.rotationX) * easing;
+ interaction.rotationY += (targetY - interaction.rotationY) * easing;
+ interaction.rotationZ +=
+ (baseRotationZ - interaction.rotationZ) *
+ (settings.animation.rotatePingPong ? 0.18 : 0.12);
+ }
+
+ mesh.rotation.set(
+ interaction.rotationX,
+ interaction.rotationY,
+ interaction.rotationZ,
+ );
+ mesh.position.y = meshOffsetY;
+ mesh.scale.setScalar(meshScale);
+
+ if (settings.animation.cameraParallaxEnabled) {
+ const cameraRange = settings.animation.cameraParallaxAmount;
+ const cameraEase = settings.animation.cameraParallaxEase;
+ const centeredX = (interaction.mouseX - 0.5) * 2;
+ const centeredY = (0.5 - interaction.mouseY) * 2;
+ const orbitYaw = centeredX * cameraRange;
+ const orbitPitch = centeredY * cameraRange * 0.7;
+ const horizontalRadius = Math.cos(orbitPitch) * baseCameraDistance;
+ const targetCameraX = Math.sin(orbitYaw) * horizontalRadius;
+ const targetCameraY =
+ Math.sin(orbitPitch) * baseCameraDistance * 0.85;
+ const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius;
+
+ camera.position.x += (targetCameraX - camera.position.x) * cameraEase;
+ camera.position.y += (targetCameraY - camera.position.y) * cameraEase;
+ camera.position.z += (targetCameraZ - camera.position.z) * cameraEase;
+ } else {
+ camera.position.x += (0 - camera.position.x) * 0.12;
+ camera.position.y += (0 - camera.position.y) * 0.12;
+ camera.position.z += (baseCameraDistance - camera.position.z) * 0.12;
+ }
+
+ const lookAtTarget = new THREE.Vector3(0, meshOffsetY * 0.2, 0);
+
+ camera.lookAt(lookAtTarget);
+ setPrimaryLightPosition(primaryLight, lightAngle, lightHeight);
+ halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale(
+ getVirtualWidth(),
+ getVirtualHeight(),
+ lookAtTarget,
+ );
+
+ if (!settings.halftone.enabled) {
+ renderer.setRenderTarget(null);
+ renderer.clear();
+ renderer.render(scene3d, camera);
+ return;
+ }
+
+ renderer.setRenderTarget(sceneTarget);
+ renderer.render(scene3d, camera);
+${emitBlurPasses()}
+ };
+
+ renderFrame();
+
+${emitCleanup(
+ ` material.dispose();\n disposeHalftoneMaterialAssets(materialAssets);`,
+ ` canvas.removeEventListener('pointermove', handlePointerMove);\n canvas.removeEventListener('pointerleave', handlePointerLeave);\n window.removeEventListener('pointerup', handlePointerUp);\n window.removeEventListener('pointermove', handleWindowPointerMove);\n canvas.removeEventListener('pointercancel', handlePointerCancel);\n window.removeEventListener('blur', handleWindowBlur);\n canvas.removeEventListener('pointerdown', handlePointerDown);`,
+)}
+}
+`;
+}
+
+function createImageMountScript() {
+ return `
+async function mountHalftoneCanvas(options) {
+ const {
+ container,
+ imageUrl,
+ onError,
+ } = options;
+
+${emitSizingHelpers()}
+
+ const image = await new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = () => reject(new Error('Failed to load image'));
+ img.src = imageUrl;
+ });
+
+${emitRendererSetup('default')}
+
+ const imageTexture = new THREE.Texture(image);
+ imageTexture.colorSpace = THREE.SRGBColorSpace;
+ imageTexture.needsUpdate = true;
+
+ const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
+ const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
+ const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
+ const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
+ const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
+
+ const imageMaterial = new THREE.ShaderMaterial({
+ uniforms: {
+ tImage: { value: imageTexture },
+ imageSize: { value: new THREE.Vector2(image.width, image.height) },
+ viewportSize: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
+ zoom: { value: getImagePreviewZoom(previewDistance) },
+ contrast: { value: settings.halftone.imageContrast },
+ },
+ vertexShader: passThroughVertexShader,
+ fragmentShader: imagePassthroughFragmentShader,
+ });
+
+ const imageScene = new THREE.Scene();
+ imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
+
+${emitBlurMaterials()}
+${emitHalftoneMaterial({ cropToBounds: 1, hoverHalftoneRadius: 'settings.animation.hoverHalftoneRadius', hoverLightRadius: 'settings.animation.hoverLightRadius', waveSpeed: 'settings.animation.waveSpeed' })}
+${emitPostScenes()}
+
+ const updateViewportUniforms = (
+ logicalWidth,
+ logicalHeight,
+ effectWidth,
+ effectHeight,
+ ) => {
+ blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
+ blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
+ halftoneMaterial.uniforms.effectResolution.value.set(
+ effectWidth,
+ effectHeight,
+ );
+ halftoneMaterial.uniforms.logicalResolution.value.set(
+ logicalWidth,
+ logicalHeight,
+ );
+ imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
+ };
+
+ const getHalftoneScale = () =>
+ getImageFootprintScale({
+ imageHeight: image.height,
+ imageWidth: image.width,
+ previewDistance,
+ viewportHeight: getVirtualHeight(),
+ viewportWidth: getVirtualWidth(),
+ });
+
+ const interaction = createInteractionState();
+ const imagePointerFollow = ${IMAGE_POINTER_FOLLOW};
+ const imagePointerVelocityDamping = ${IMAGE_POINTER_VELOCITY_DAMPING};
+ const imageHoverFadeIn = ${IMAGE_HOVER_FADE_IN};
+ const imageHoverFadeOut = ${IMAGE_HOVER_FADE_OUT};
+
+ const syncSize = () => {
+ const virtualWidth = getVirtualWidth();
+ const virtualHeight = getVirtualHeight();
+
+ renderer.setSize(virtualWidth, virtualHeight, false);
+ sceneTarget.setSize(virtualWidth, virtualHeight);
+ blurTargetA.setSize(virtualWidth, virtualHeight);
+ blurTargetB.setSize(virtualWidth, virtualHeight);
+ updateViewportUniforms(
+ virtualWidth,
+ virtualHeight,
+ virtualWidth,
+ virtualHeight,
+ );
+ };
+
+ const resizeObserver = new ResizeObserver(syncSize);
+ resizeObserver.observe(container);
+
+ const updatePointerPosition = (event, options = {}) => {
+ const rect = canvas.getBoundingClientRect();
+ const width = Math.max(rect.width, 1);
+ const height = Math.max(rect.height, 1);
+
+ const nextMouseX = THREE.MathUtils.clamp(
+ (event.clientX - rect.left) / width, 0, 1,
+ );
+ const nextMouseY = THREE.MathUtils.clamp(
+ (event.clientY - rect.top) / height, 0, 1,
+ );
+
+ const deltaX = nextMouseX - interaction.mouseX;
+ const deltaY = nextMouseY - interaction.mouseY;
+
+ interaction.mouseX = nextMouseX;
+ interaction.mouseY = nextMouseY;
+ interaction.pointerInside =
+ interaction.dragging ||
+ (event.clientX >= rect.left &&
+ event.clientX <= rect.right &&
+ event.clientY >= rect.top &&
+ event.clientY <= rect.bottom);
+
+ if (options.resetVelocity) {
+ interaction.pointerVelocityX = 0;
+ interaction.pointerVelocityY = 0;
+ interaction.smoothedMouseX = nextMouseX;
+ interaction.smoothedMouseY = nextMouseY;
+ } else {
+ interaction.pointerVelocityX = deltaX;
+ interaction.pointerVelocityY = deltaY;
+ }
+
+ return { deltaX, deltaY };
+ };
+
+ const releasePointerCapture = (pointerId) => {
+ if (pointerId === null) {
+ return;
+ }
+
+ if (!canvas.hasPointerCapture(pointerId)) {
+ return;
+ }
+
+ try {
+ canvas.releasePointerCapture(pointerId);
+ } catch (error) {
+ void error;
+ }
+ };
+
+ const handlePointerDown = (event) => {
+ updatePointerPosition(event, { resetVelocity: true });
+ interaction.pointerX = event.clientX;
+ interaction.pointerY = event.clientY;
+ };
+
+ const handlePointerMove = (event) => {
+ const resetVelocity = !interaction.pointerInside && !interaction.dragging;
+ updatePointerPosition(
+ event,
+ resetVelocity ? { resetVelocity: true } : undefined,
+ );
+ };
+
+ const handlePointerLeave = () => {
+ if (interaction.dragging) {
+ return;
+ }
+
+ interaction.pointerInside = false;
+ interaction.pointerVelocityX = 0;
+ interaction.pointerVelocityY = 0;
+ };
+
+ const handlePointerUp = (event) => {
+ updatePointerPosition(event, { resetVelocity: true });
+ releasePointerCapture(interaction.activePointerId);
+ interaction.activePointerId = null;
+ interaction.dragging = false;
+ const rect = canvas.getBoundingClientRect();
+ interaction.pointerInside =
+ event.clientX >= rect.left &&
+ event.clientX <= rect.right &&
+ event.clientY >= rect.top &&
+ event.clientY <= rect.bottom;
+ };
+
+ const handlePointerCancel = () => {
+ releasePointerCapture(interaction.activePointerId);
+ interaction.activePointerId = null;
+ interaction.dragging = false;
+ interaction.pointerInside = false;
+ interaction.pointerVelocityX = 0;
+ interaction.pointerVelocityY = 0;
+ };
+
+ const handleWindowBlur = () => {
+ handlePointerCancel();
+ };
+
+ canvas.addEventListener('pointermove', handlePointerMove);
+ canvas.addEventListener('pointerleave', handlePointerLeave);
+ canvas.addEventListener('pointerup', handlePointerUp);
+ canvas.addEventListener('pointercancel', handlePointerCancel);
+ window.addEventListener('blur', handleWindowBlur);
+ canvas.addEventListener('pointerdown', handlePointerDown);
+
+ const clock = new THREE.Timer();
+ clock.connect(document);
+ let animationFrameId = 0;
+
+ const renderFrame = (timestamp) => {
+ // boundary-allow-next-line:no-raw-animation-frame -- emitted into standalone HTML export
+ animationFrameId = window.requestAnimationFrame(renderFrame);
+ clock.update(timestamp);
+
+ const deltaSeconds = clock.getDelta();
+ const elapsedTime = clock.getElapsed();
+ halftoneMaterial.uniforms.time.value = elapsedTime;
+ const hoverEasing =
+ 1 -
+ Math.exp(
+ -deltaSeconds *
+ (interaction.pointerInside ? imageHoverFadeIn : imageHoverFadeOut),
+ );
+ interaction.hoverStrength +=
+ ((interaction.pointerInside ? 1 : 0) - interaction.hoverStrength) *
+ hoverEasing;
+
+ interaction.smoothedMouseX +=
+ (interaction.mouseX - interaction.smoothedMouseX) * imagePointerFollow;
+ interaction.smoothedMouseY +=
+ (interaction.mouseY - interaction.smoothedMouseY) * imagePointerFollow;
+ interaction.pointerVelocityX *= imagePointerVelocityDamping;
+ interaction.pointerVelocityY *= imagePointerVelocityDamping;
+
+ halftoneMaterial.uniforms.interactionUv.value.set(
+ interaction.smoothedMouseX,
+ 1 - interaction.smoothedMouseY,
+ );
+ halftoneMaterial.uniforms.interactionVelocity.value.set(
+ interaction.pointerVelocityX * getVirtualWidth(),
+ -interaction.pointerVelocityY * getVirtualHeight(),
+ );
+ halftoneMaterial.uniforms.dragOffset.value.set(0, 0);
+ halftoneMaterial.uniforms.hoverHalftoneActive.value =
+ settings.animation.hoverHalftoneEnabled ? interaction.hoverStrength : 0;
+ halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
+ settings.animation.hoverHalftoneEnabled
+ ? settings.animation.hoverHalftonePowerShift
+ : 0;
+ halftoneMaterial.uniforms.hoverHalftoneRadius.value =
+ settings.animation.hoverHalftoneRadius;
+ halftoneMaterial.uniforms.hoverHalftoneWidthShift.value =
+ settings.animation.hoverHalftoneEnabled
+ ? settings.animation.hoverHalftoneWidthShift
+ : 0;
+ halftoneMaterial.uniforms.hoverLightStrength.value =
+ settings.animation.hoverLightEnabled
+ ? settings.animation.hoverLightIntensity * interaction.hoverStrength
+ : 0;
+ halftoneMaterial.uniforms.hoverLightRadius.value =
+ settings.animation.hoverLightRadius;
+ halftoneMaterial.uniforms.hoverFlowStrength.value = 0;
+ halftoneMaterial.uniforms.hoverFlowRadius.value = 0.18;
+ halftoneMaterial.uniforms.dragFlowStrength.value = 0;
+ imageMaterial.uniforms.zoom.value = getImagePreviewZoom(previewDistance);
+ halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
+
+ renderer.setRenderTarget(sceneTarget);
+ renderer.render(imageScene, orthographicCamera);
+${emitBlurPasses()}
+ };
+
+ renderFrame();
+
+${emitCleanup(
+ ` imageMaterial.dispose();\n imageTexture.dispose();`,
+ ` canvas.removeEventListener('pointermove', handlePointerMove);\n canvas.removeEventListener('pointerleave', handlePointerLeave);\n canvas.removeEventListener('pointerup', handlePointerUp);\n canvas.removeEventListener('pointercancel', handlePointerCancel);\n window.removeEventListener('blur', handleWindowBlur);\n canvas.removeEventListener('pointerdown', handlePointerDown);`,
+)}
+}
+`;
+}
+
+export function getExportedModelFile(
+ shape: HalftoneGeometrySpec | undefined,
+ importedFile: File | undefined,
+) {
+ if (!shape || shape.kind !== 'imported' || !importedFile) {
+ return null;
+ }
+
+ return importedFile;
+}
+
+export function generateReactComponent(
+ settings: HalftoneStudioSettings,
+ selectedShape: HalftoneGeometrySpec | undefined,
+ componentName = 'HalftoneDashes',
+ options: ReactExportOptions = {},
+) {
+ const isImageMode = settings.sourceMode === 'image';
+ const exportSettings = normalizeReactExportSettings(options.exportSettings);
+ const shape = createShapeDescriptor(
+ selectedShape,
+ settings,
+ options.importedFile,
+ options.modelFilenameOverride,
+ );
+ const pose = normalizeExportPose(options.initialPose);
+ const normalizedComponentName = normalizeExportComponentName(componentName);
+ const background = options.background ?? 'transparent';
+ const assetUrl =
+ exportSettings.includePublicAssetUrl && options.assetUrl
+ ? options.assetUrl
+ : null;
+ const defaultModelFilename =
+ options.modelFilenameOverride ?? shape.filename ?? 'model.glb';
+ const defaultImageFilename = options.imageFilename ?? 'image.png';
+ const defaultModelUrl = assetUrl ?? `./${defaultModelFilename}`;
+ const defaultImageUrl = assetUrl ?? `./${defaultImageFilename}`;
+ const importBlock = getReactImportBlock(exportSettings, isImageMode, shape);
+ const headerComment = exportSettings.includeRegistryComment
+ ? getTwentyReactHeaderComment(
+ normalizedComponentName,
+ toIllustrationRegistryKey(normalizedComponentName),
+ isImageMode || shape.kind === 'imported'
+ ? (assetUrl ?? undefined)
+ : undefined,
+ )
+ : '';
+ const mountScript = isImageMode
+ ? createImageMountScript()
+ : createMountScript();
+ const serializedRuntime = serializeRuntimeSource(
+ settings,
+ shape,
+ pose,
+ options.previewDistance,
+ );
+ const generatedBanner = exportSettings.includeTsNoCheck
+ ? `/**
+ * @generated halftone-studio
+ * This file is autogenerated by the in-app halftone studio
+ * (src/app/halftone). Do NOT hand-edit — regenerate the export
+ * from the studio if the visual or runtime behaviour needs to change.
+ *
+ * Type checking is disabled at the file level because the serialized
+ * Three.js runtime below contains thousands of lines of legacy
+ * implicitly-typed code that the generator does not annotate. See
+ * src/app/halftone/_lib/exporters.ts for the generator source.
+ */
+// @ts-nocheck`
+ : null;
+ const directiveLines = [
+ generatedBanner,
+ exportSettings.includeUseClientDirective ? "'use client';" : null,
+ ]
+ .filter((line): line is string => line !== null)
+ .join('\n');
+ const mountStyleBlock = exportSettings.includeStyledMount
+ ? `const StyledVisualMount = styled.div\`
+ background: ${background};
+ display: block;
+ height: 100%;
+ min-width: 0;
+ width: 100%;
+\`;
+`
+ : '';
+ const assetPropName = isImageMode
+ ? 'imageUrl'
+ : shape.kind === 'imported'
+ ? 'modelUrl'
+ : null;
+ const assetPropDefaultValue =
+ assetPropName === 'imageUrl'
+ ? defaultImageUrl
+ : assetPropName === 'modelUrl'
+ ? defaultModelUrl
+ : null;
+ const propsTypeBlock = assetPropName
+ ? `type ${normalizedComponentName}Props = {\n ${assetPropName}?: string;\n style?: CSSProperties;\n};`
+ : `type ${normalizedComponentName}Props = {\n style?: CSSProperties;\n};`;
+ const propsSignature = assetPropName
+ ? `{\n ${assetPropName} = ${JSON.stringify(assetPropDefaultValue)},\n style,\n}: ${normalizedComponentName}Props`
+ : `{\n style,\n}: ${normalizedComponentName}Props`;
+ const mountOptionsBlock = assetPropName
+ ? `const unmount = mountHalftoneCanvas({\n container,\n ${assetPropName},\n onError: (error) => {\n console.error(error);\n },\n });`
+ : `const unmount = mountHalftoneCanvas({\n container,\n onError: (error) => {\n console.error(error);\n },\n });`;
+ const effectDependencies = assetPropName ? `[${assetPropName}]` : '[]';
+ const returnBlock = exportSettings.includeStyledMount
+ ? `return ;`
+ : `return (
+
+ );`;
+ const componentFunctionBlock = exportSettings.includeNamedAndDefaultExport
+ ? `export function ${normalizedComponentName}(${propsSignature}) {
+ const mountReference = useRef(null);
+
+ useEffect(() => {
+ const container = mountReference.current;
+
+ if (!container) {
+ return;
+ }
+
+ ${mountOptionsBlock}
+
+ return () => {
+ void Promise.resolve(unmount).then((dispose) => dispose?.());
+ };
+ }, ${effectDependencies});
+
+ ${returnBlock}
+}
+
+export default ${normalizedComponentName};`
+ : `export default function ${normalizedComponentName}(${propsSignature}) {
+ const mountReference = useRef(null);
+
+ useEffect(() => {
+ const container = mountReference.current;
+
+ if (!container) {
+ return;
+ }
+
+ ${mountOptionsBlock}
+
+ return () => {
+ void Promise.resolve(unmount).then((dispose) => dispose?.());
+ };
+ }, ${effectDependencies});
+
+ ${returnBlock}
+}`;
+
+ return `${directiveLines ? `${directiveLines}\n\n` : ''}${importBlock}
+
+${headerComment}${serializedRuntime}
+
+${mountScript}
+
+${mountStyleBlock ? `${mountStyleBlock}\n` : ''}${propsTypeBlock}
+
+${componentFunctionBlock}
+`;
+}
+
+export async function generateStandaloneHtml(
+ settings: HalftoneStudioSettings,
+ selectedShape: HalftoneGeometrySpec | undefined,
+ componentName = 'HalftoneDashes',
+ options: ReactExportOptions = {},
+) {
+ const isImageMode = settings.sourceMode === 'image';
+ const shape = createShapeDescriptor(
+ selectedShape,
+ settings,
+ options.importedFile,
+ options.modelFilenameOverride,
+ );
+ const pose = normalizeExportPose(options.initialPose);
+ const normalizedComponentName = normalizeExportComponentName(componentName);
+ const defaultImageUrl = options.imageFilename ?? 'image.png';
+ const embeddedImportedModelUrl =
+ !isImageMode && shape.kind === 'imported' && options.importedFile
+ ? await fileToDataUrl(options.importedFile, shape.loader)
+ : null;
+ const defaultModelUrl =
+ embeddedImportedModelUrl ??
+ options.modelFilenameOverride ??
+ shape.filename ??
+ 'model.glb';
+ const background = options.background ?? 'transparent';
+
+ const threeImports = getStandaloneThreeImports(isImageMode, shape);
+
+ const mountScript = isImageMode
+ ? createImageMountScript()
+ : createMountScript();
+
+ const mountCall = isImageMode
+ ? `mountHalftoneCanvas({
+ container,
+ imageUrl: ${JSON.stringify(`./${defaultImageUrl}`)},
+ onError: (error) => {
+ console.error(error);
+ },
+ });`
+ : `mountHalftoneCanvas({
+ container,
+ modelUrl: ${JSON.stringify(`./${defaultModelUrl}`)},
+ onError: (error) => {
+ console.error(error);
+ },
+ });`;
+
+ const captionText = isImageMode
+ ? `Place ${defaultImageUrl} next to this HTML file.`
+ : `Standalone export of the current halftone scene.
+ ${
+ shape.kind === 'imported'
+ ? embeddedImportedModelUrl
+ ? 'The uploaded model is embedded directly in this HTML file.'
+ : `Place ${defaultModelUrl} next to this HTML file to keep the current uploaded shape.`
+ : ''
+ }`;
+
+ return `
+
+
+
+
+ ${normalizedComponentName}
+
+
+
+
+
+
+
+
+
+`;
+}
diff --git a/packages/twenty-website-new/src/lib/halftone/footprint.ts b/packages/twenty-website-new/src/lib/halftone/utils/footprint.ts
similarity index 53%
rename from packages/twenty-website-new/src/lib/halftone/footprint.ts
rename to packages/twenty-website-new/src/lib/halftone/utils/footprint.ts
index c5ac2f4e51..debc85963e 100644
--- a/packages/twenty-website-new/src/lib/halftone/footprint.ts
+++ b/packages/twenty-website-new/src/lib/halftone/utils/footprint.ts
@@ -33,7 +33,7 @@ export interface MeshFootprintScaleArgs extends ProjectedBoundsArgs {
export const VIRTUAL_RENDER_HEIGHT = 768;
export const REFERENCE_PREVIEW_DISTANCE = 4;
-const MIN_FOOTPRINT_SCALE = 0.001;
+export const MIN_FOOTPRINT_SCALE = 0.001;
function clampRectToViewport(
rect: HalftoneRect,
@@ -277,244 +277,3 @@ export function getMeshFootprintScale({
return getFootprintScaleFromRects(currentRect, referenceRect);
}
-
-export const HALFTONE_FOOTPRINT_RUNTIME_SOURCE = String.raw`
-const REFERENCE_PREVIEW_DISTANCE = ${REFERENCE_PREVIEW_DISTANCE};
-const MIN_FOOTPRINT_SCALE = ${MIN_FOOTPRINT_SCALE};
-
-function clampRectToViewport(rect, viewportWidth, viewportHeight) {
- 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 {
- x: minX,
- y: minY,
- width: maxX - minX,
- height: maxY - minY,
- };
-}
-
-function getRectArea(rect) {
- if (!rect) {
- return 0;
- }
-
- return Math.max(rect.width, 0) * Math.max(rect.height, 0);
-}
-
-function createBox3Corners(bounds) {
- const { min, max } = bounds;
-
- return [
- new THREE.Vector3(min.x, min.y, min.z),
- new THREE.Vector3(min.x, min.y, max.z),
- new THREE.Vector3(min.x, max.y, min.z),
- new THREE.Vector3(min.x, max.y, max.z),
- new THREE.Vector3(max.x, min.y, min.z),
- new THREE.Vector3(max.x, min.y, max.z),
- new THREE.Vector3(max.x, max.y, min.z),
- new THREE.Vector3(max.x, max.y, max.z),
- ];
-}
-
-function getImagePreviewZoom(previewDistance) {
- return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
-}
-
-function getContainedImageRect({
- imageFit = 'contain',
- imageHeight,
- imageWidth,
- viewportHeight,
- viewportWidth,
- zoom,
-}) {
- if (
- imageWidth <= 0 ||
- imageHeight <= 0 ||
- viewportWidth <= 0 ||
- viewportHeight <= 0
- ) {
- return null;
- }
-
- const imageAspect = imageWidth / imageHeight;
- const viewAspect = viewportWidth / viewportHeight;
-
- let fittedWidth = viewportWidth;
- let fittedHeight = viewportHeight;
-
- if (imageAspect > viewAspect) {
- if (imageFit === 'cover') {
- fittedWidth = viewportHeight * imageAspect;
- } else {
- fittedHeight = viewportWidth / imageAspect;
- }
- } else {
- if (imageFit === 'cover') {
- fittedHeight = viewportWidth / imageAspect;
- } else {
- fittedWidth = viewportHeight * imageAspect;
- }
- }
-
- const scaledWidth = fittedWidth * zoom;
- const scaledHeight = fittedHeight * zoom;
-
- return clampRectToViewport(
- {
- x: (viewportWidth - scaledWidth) * 0.5,
- y: (viewportHeight - scaledHeight) * 0.5,
- width: scaledWidth,
- height: scaledHeight,
- },
- viewportWidth,
- viewportHeight,
- );
-}
-
-function getFootprintScaleFromRects(currentRect, referenceRect) {
- 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({
- imageFit = 'contain',
- imageHeight,
- imageWidth,
- previewDistance,
- viewportHeight,
- viewportWidth,
-}) {
- const currentRect = getContainedImageRect({
- imageFit,
- imageHeight,
- imageWidth,
- viewportHeight,
- viewportWidth,
- zoom: getImagePreviewZoom(previewDistance),
- });
- const referenceRect = getContainedImageRect({
- imageFit,
- imageHeight,
- imageWidth,
- viewportHeight,
- viewportWidth,
- zoom: 1,
- });
-
- return getFootprintScaleFromRects(currentRect, referenceRect);
-}
-
-function projectBox3ToViewport({
- camera,
- localBounds,
- meshMatrixWorld,
- viewportHeight,
- viewportWidth,
-}) {
- if (
- localBounds.isEmpty() ||
- viewportWidth <= 0 ||
- viewportHeight <= 0
- ) {
- return null;
- }
-
- let minX = Number.POSITIVE_INFINITY;
- let minY = Number.POSITIVE_INFINITY;
- let maxX = Number.NEGATIVE_INFINITY;
- let maxY = Number.NEGATIVE_INFINITY;
- let hasProjectedCorner = false;
-
- for (const corner of createBox3Corners(localBounds)) {
- corner.applyMatrix4(meshMatrixWorld).project(camera);
-
- if (
- !Number.isFinite(corner.x) ||
- !Number.isFinite(corner.y) ||
- !Number.isFinite(corner.z)
- ) {
- continue;
- }
-
- hasProjectedCorner = true;
-
- const x = (corner.x * 0.5 + 0.5) * viewportWidth;
- const y = (1 - (corner.y * 0.5 + 0.5)) * viewportHeight;
-
- minX = Math.min(minX, x);
- minY = Math.min(minY, y);
- maxX = Math.max(maxX, x);
- maxY = Math.max(maxY, y);
- }
-
- if (!hasProjectedCorner) {
- return null;
- }
-
- return clampRectToViewport(
- {
- x: minX,
- y: minY,
- width: maxX - minX,
- height: maxY - minY,
- },
- viewportWidth,
- viewportHeight,
- );
-}
-
-function getMeshFootprintScale({
- camera,
- localBounds,
- lookAtTarget,
- meshMatrixWorld,
- viewportHeight,
- viewportWidth,
-}) {
- const currentRect = projectBox3ToViewport({
- camera,
- localBounds,
- meshMatrixWorld,
- viewportHeight,
- viewportWidth,
- });
- const referenceCamera = camera.clone();
- const currentOffset = referenceCamera.position.clone().sub(lookAtTarget);
- const referenceOffset =
- currentOffset.lengthSq() > 0
- ? currentOffset.setLength(REFERENCE_PREVIEW_DISTANCE)
- : new THREE.Vector3(0, 0, REFERENCE_PREVIEW_DISTANCE);
-
- referenceCamera.position.copy(lookAtTarget).add(referenceOffset);
- referenceCamera.lookAt(lookAtTarget);
- referenceCamera.updateProjectionMatrix();
- referenceCamera.updateMatrixWorld(true);
-
- const referenceRect = projectBox3ToViewport({
- camera: referenceCamera,
- localBounds,
- meshMatrixWorld,
- viewportHeight,
- viewportWidth,
- });
-
- return getFootprintScaleFromRects(currentRect, referenceRect);
-}
-`;
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/formatters.ts b/packages/twenty-website-new/src/lib/halftone/utils/formatters.ts
similarity index 74%
rename from packages/twenty-website-new/src/app/[locale]/halftone/_lib/formatters.ts
rename to packages/twenty-website-new/src/lib/halftone/utils/formatters.ts
index 005de23689..e5d6dd4265 100644
--- a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/formatters.ts
+++ b/packages/twenty-website-new/src/lib/halftone/utils/formatters.ts
@@ -84,29 +84,3 @@ export function formatAnimationName(
return activeModes.length > 0 ? activeModes.join(' + ') : 'still';
}
-
-export function getBackgroundTone(background: {
- color: string;
- transparent: boolean;
-}) {
- if (background.transparent) {
- return 'light';
- }
-
- const normalized = background.color.replace('#', '');
- const expanded =
- normalized.length === 3
- ? normalized
- .split('')
- .map((character) => `${character}${character}`)
- .join('')
- : normalized;
-
- const value = Number.parseInt(expanded, 16);
- const red = (value >> 16) & 255;
- const green = (value >> 8) & 255;
- const blue = value & 255;
- const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
-
- return luminance < 0.5 ? 'dark' : 'light';
-}
diff --git a/packages/twenty-website-new/src/lib/halftone/utils/geometry-builtins.ts b/packages/twenty-website-new/src/lib/halftone/utils/geometry-builtins.ts
new file mode 100644
index 0000000000..cf9a49d3ea
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/halftone/utils/geometry-builtins.ts
@@ -0,0 +1,392 @@
+import * as THREE from 'three';
+import { mergeGeometries } from './geometry-loaders';
+
+function makePolarShape(
+ radiusFunction: (angle: number) => number,
+ segments = 320,
+) {
+ const shape = new THREE.Shape();
+
+ for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
+ const angle = (segmentIndex / segments) * Math.PI * 2;
+ const radius = radiusFunction(angle);
+ const x = Math.cos(angle) * radius;
+ const y = Math.sin(angle) * radius;
+
+ if (segmentIndex === 0) {
+ shape.moveTo(x, y);
+ } else {
+ shape.lineTo(x, y);
+ }
+ }
+
+ return shape;
+}
+
+function makeReliefGeometry(
+ shape: THREE.Shape,
+ options: {
+ depth?: number;
+ bevelSize?: number;
+ bevelThickness?: number;
+ bevelSegments?: number;
+ waves?: number;
+ waveDepth?: number;
+ } = {},
+) {
+ const {
+ bevelSegments = 8,
+ bevelSize = 0.08,
+ bevelThickness = 0.1,
+ depth = 0.58,
+ waveDepth = 0.016,
+ waves = 8,
+ } = options;
+
+ const geometry = new THREE.ExtrudeGeometry(shape, {
+ depth,
+ steps: 2,
+ bevelEnabled: true,
+ bevelThickness,
+ bevelSize,
+ bevelSegments,
+ curveSegments: 96,
+ });
+
+ geometry.center();
+
+ const position = geometry.attributes.position;
+ let maxRadius = 0;
+
+ for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) {
+ maxRadius = Math.max(
+ maxRadius,
+ Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)),
+ );
+ }
+
+ const fullDepth = depth + bevelThickness * 2;
+
+ for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) {
+ const x = position.getX(vertexIndex);
+ const y = position.getY(vertexIndex);
+ const z = position.getZ(vertexIndex);
+ const radius = Math.hypot(x, y) / maxRadius;
+ const angle = Math.atan2(y, x);
+ const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5));
+ const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2));
+ const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2));
+ const wave =
+ Math.cos(angle * waves) *
+ Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) *
+ waveDepth;
+ const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave);
+
+ position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief);
+ }
+
+ position.needsUpdate = true;
+ geometry.computeVertexNormals();
+ geometry.computeBoundingBox();
+ geometry.computeBoundingSphere();
+
+ return geometry;
+}
+
+function makeArrowTarget() {
+ const targetParts: THREE.BufferGeometry[] = [];
+ const arrowParts: THREE.BufferGeometry[] = [];
+ const baseRadius = 1.35;
+ const baseDepth = 0.32;
+ const bevel = 0.12;
+ const points: THREE.Vector2[] = [];
+ const segments = 16;
+
+ points.push(new THREE.Vector2(0, -baseDepth / 2));
+ points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2));
+
+ for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
+ const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2);
+ points.push(
+ new THREE.Vector2(
+ baseRadius - bevel + Math.cos(angle) * bevel,
+ -baseDepth / 2 + bevel + Math.sin(angle) * bevel,
+ ),
+ );
+ }
+
+ points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel));
+
+ for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
+ const angle = (segmentIndex / segments) * (Math.PI / 2);
+ points.push(
+ new THREE.Vector2(
+ baseRadius - bevel + Math.cos(angle) * bevel,
+ baseDepth / 2 - bevel + Math.sin(angle) * bevel,
+ ),
+ );
+ }
+
+ points.push(new THREE.Vector2(0, baseDepth / 2));
+
+ const disc = new THREE.LatheGeometry(points, 64);
+ disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
+ targetParts.push(disc);
+
+ for (const radius of [0.45, 0.85, 1.22]) {
+ const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64);
+ ring.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04),
+ );
+ targetParts.push(ring);
+ }
+
+ const bump = new THREE.SphereGeometry(
+ 0.32,
+ 32,
+ 24,
+ 0,
+ Math.PI * 2,
+ 0,
+ Math.PI / 2,
+ );
+ bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2));
+ bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2));
+ targetParts.push(bump);
+
+ const shaftLength = 1.5;
+ const shaftRadius = 0.05;
+ const shaft = new THREE.CylinderGeometry(
+ shaftRadius,
+ shaftRadius,
+ shaftLength,
+ 10,
+ 1,
+ );
+ shaft.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0),
+ );
+ arrowParts.push(shaft);
+
+ const head = new THREE.ConeGeometry(0.12, 0.35, 10);
+ head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0));
+ arrowParts.push(head);
+
+ for (let finIndex = 0; finIndex < 3; finIndex += 1) {
+ const finShape = new THREE.Shape();
+ finShape.moveTo(0, 0);
+ finShape.lineTo(0.22, 0.25);
+ finShape.lineTo(0, 0.5);
+ finShape.lineTo(0, 0);
+
+ const finGeometry = new THREE.ExtrudeGeometry(finShape, {
+ depth: 0.012,
+ bevelEnabled: false,
+ });
+
+ finGeometry.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0.05, 0, -0.006),
+ );
+ finGeometry.applyMatrix4(
+ new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3),
+ );
+ finGeometry.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0),
+ );
+ arrowParts.push(finGeometry);
+ }
+
+ const nock = new THREE.SphereGeometry(0.065, 8, 8);
+ nock.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0),
+ );
+ arrowParts.push(nock);
+
+ const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15);
+ const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5);
+ const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12);
+
+ for (const geometry of arrowParts) {
+ geometry.applyMatrix4(aim);
+ geometry.applyMatrix4(tilt);
+ geometry.applyMatrix4(shift);
+ }
+
+ const merged = mergeGeometries([...targetParts, ...arrowParts]);
+ merged.computeVertexNormals();
+ merged.computeBoundingSphere();
+
+ return merged;
+}
+
+function makeDollarCoin() {
+ const parts: THREE.BufferGeometry[] = [];
+ const baseRadius = 1.3;
+ const baseDepth = 0.45;
+ const bevel = 0.18;
+ const points: THREE.Vector2[] = [];
+ const segments = 20;
+
+ points.push(new THREE.Vector2(0, -baseDepth / 2));
+ points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2));
+
+ for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
+ const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI;
+ points.push(
+ new THREE.Vector2(
+ baseRadius - bevel + Math.cos(angle) * bevel,
+ Math.sin(angle) * (baseDepth / 2),
+ ),
+ );
+ }
+
+ points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2));
+ points.push(new THREE.Vector2(0, baseDepth / 2));
+
+ const disc = new THREE.LatheGeometry(points, 64);
+ disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
+ parts.push(disc);
+
+ const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64);
+ frontRim.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01),
+ );
+ parts.push(frontRim);
+
+ const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64);
+ backRim.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)),
+ );
+ parts.push(backRim);
+
+ const createDollarSign = () => {
+ const geometries: THREE.BufferGeometry[] = [];
+ const tubeRadius = 0.1;
+ const curveRadius = 0.28;
+ const verticalOffset = 0.22;
+
+ const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12);
+ geometries.push(bar);
+
+ const topArc = new THREE.TorusGeometry(
+ curveRadius,
+ tubeRadius,
+ 16,
+ 32,
+ Math.PI,
+ );
+ topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
+ topArc.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0),
+ );
+ geometries.push(topArc);
+
+ const bottomArc = new THREE.TorusGeometry(
+ curveRadius,
+ tubeRadius,
+ 16,
+ 32,
+ Math.PI,
+ );
+ bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2));
+ bottomArc.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0),
+ );
+ geometries.push(bottomArc);
+
+ const topSerif = new THREE.CylinderGeometry(
+ tubeRadius,
+ tubeRadius,
+ 0.22,
+ 12,
+ );
+ topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
+ topSerif.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(
+ 0.16,
+ verticalOffset + curveRadius,
+ 0,
+ ),
+ );
+ geometries.push(topSerif);
+
+ const bottomSerif = new THREE.CylinderGeometry(
+ tubeRadius,
+ tubeRadius,
+ 0.22,
+ 12,
+ );
+ bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
+ bottomSerif.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(
+ -0.16,
+ -verticalOffset - curveRadius,
+ 0,
+ ),
+ );
+ geometries.push(bottomSerif);
+
+ const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2);
+ const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1);
+ const diagonal = new THREE.CylinderGeometry(
+ tubeRadius,
+ tubeRadius,
+ diagonalLength + 0.12,
+ 12,
+ );
+ diagonal.applyMatrix4(
+ new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2),
+ );
+ geometries.push(diagonal);
+
+ return geometries;
+ };
+
+ for (const geometry of createDollarSign()) {
+ geometry.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01),
+ );
+ parts.push(geometry);
+ }
+
+ for (const geometry of createDollarSign()) {
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI));
+ geometry.applyMatrix4(
+ new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)),
+ );
+ parts.push(geometry);
+ }
+
+ const merged = mergeGeometries(parts);
+ merged.computeVertexNormals();
+ merged.computeBoundingSphere();
+
+ return merged;
+}
+
+export const BUILTIN_GEOMETRIES: Record THREE.BufferGeometry> = {
+ torusKnot: () => new THREE.TorusKnotGeometry(1, 0.35, 200, 32),
+ sphere: () => new THREE.SphereGeometry(1.4, 64, 64),
+ torus: () => new THREE.TorusGeometry(1, 0.45, 64, 100),
+ icosahedron: () => new THREE.IcosahedronGeometry(1.4, 4),
+ box: () => new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6),
+ cone: () => new THREE.ConeGeometry(1.2, 2.4, 64, 10),
+ cylinder: () => new THREE.CylinderGeometry(1, 1, 2.3, 64, 10),
+ octahedron: () => new THREE.OctahedronGeometry(1.5, 2),
+ dodecahedron: () => new THREE.DodecahedronGeometry(1.35, 1),
+ tetrahedron: () => new THREE.TetrahedronGeometry(1.7, 1),
+ sunCoin: () =>
+ makeReliefGeometry(
+ makePolarShape(
+ (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5),
+ ),
+ { depth: 0.62, waves: 12, waveDepth: 0.018 },
+ ),
+ lotusCoin: () =>
+ makeReliefGeometry(
+ makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)),
+ { depth: 0.64, waves: 8, waveDepth: 0.014 },
+ ),
+ arrowTarget: () => makeArrowTarget(),
+ dollarCoin: () => makeDollarCoin(),
+};
diff --git a/packages/twenty-website-new/src/lib/halftone/utils/geometry-cache.ts b/packages/twenty-website-new/src/lib/halftone/utils/geometry-cache.ts
new file mode 100644
index 0000000000..6763cbf7e8
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/halftone/utils/geometry-cache.ts
@@ -0,0 +1,94 @@
+import type * as THREE from 'three';
+import {
+ loadImportedGeometry,
+ type HalftoneModelLoader,
+} from './geometry-loaders';
+import { BUILTIN_GEOMETRIES } from './geometry-builtins';
+
+interface HalftoneGeometrySpec {
+ key: string;
+ label: string;
+ kind: 'builtin' | 'imported';
+ loader?: HalftoneModelLoader;
+ filename?: string;
+ description?: string;
+ extensions?: readonly string[];
+ userProvided?: boolean;
+}
+
+export type GeometryCacheEntry =
+ | THREE.BufferGeometry
+ | Promise;
+
+async function createGeometry(
+ spec: HalftoneGeometrySpec,
+ file: File | undefined,
+) {
+ if (spec.kind === 'imported') {
+ if (!file || !spec.loader) {
+ throw new Error(`No file is available for ${spec.label}.`);
+ }
+
+ return loadImportedGeometry(
+ spec.loader as HalftoneModelLoader,
+ file,
+ spec.label,
+ );
+ }
+
+ const factory = BUILTIN_GEOMETRIES[spec.key];
+
+ if (!factory) {
+ throw new Error(`Unsupported geometry: ${spec.key}.`);
+ }
+
+ return factory();
+}
+
+export function createFallbackGeometry() {
+ return BUILTIN_GEOMETRIES.torusKnot();
+}
+
+export async function getGeometryForSpec(
+ spec: HalftoneGeometrySpec,
+ file: File | undefined,
+ cache: Map,
+) {
+ const cached = cache.get(spec.key);
+
+ if (cached) {
+ return cached instanceof Promise ? cached : Promise.resolve(cached);
+ }
+
+ const created = createGeometry(spec, file);
+
+ if (created instanceof Promise) {
+ const pending = created
+ .then((geometry) => {
+ cache.set(spec.key, geometry);
+ return geometry;
+ })
+ .catch((error) => {
+ cache.delete(spec.key);
+ throw error;
+ });
+
+ cache.set(spec.key, pending);
+
+ return pending;
+ }
+
+ cache.set(spec.key, created);
+
+ return Promise.resolve(created);
+}
+
+export function disposeGeometryCache(cache: Map) {
+ for (const value of cache.values()) {
+ if (!(value instanceof Promise)) {
+ value.dispose();
+ }
+ }
+
+ cache.clear();
+}
diff --git a/packages/twenty-website-new/src/lib/halftone/utils/geometry-loaders.ts b/packages/twenty-website-new/src/lib/halftone/utils/geometry-loaders.ts
new file mode 100644
index 0000000000..84c330e24d
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/halftone/utils/geometry-loaders.ts
@@ -0,0 +1,364 @@
+import * as THREE from 'three';
+import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
+import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
+import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/utils/draco-decoder-path';
+
+export type HalftoneModelLoader = 'fbx' | 'glb';
+
+export type ImportedGeometryNormalizationOptions = {
+ postRotateZ?: number;
+ scaleTarget?: number;
+ useLegacyNormalization?: boolean;
+};
+
+const LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET = 2.75;
+
+const EMPTY_TEXTURE_DATA_URL =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
+
+export function mergeGeometries(geometries: THREE.BufferGeometry[]) {
+ if (geometries.length === 1) {
+ return geometries[0];
+ }
+
+ let totalVertices = 0;
+ let totalIndices = 0;
+ let hasUv = false;
+
+ const geometryInfos = geometries.map((geometry) => {
+ const position = geometry.attributes.position;
+ const normal = geometry.attributes.normal;
+ const uv = geometry.attributes.uv ?? null;
+ const index = geometry.index;
+ const indexCount = index ? index.count : position.count;
+
+ totalVertices += position.count;
+ totalIndices += indexCount;
+ hasUv = hasUv || uv !== null;
+
+ return {
+ index,
+ indexCount,
+ normal,
+ position,
+ uv,
+ vertexCount: position.count,
+ };
+ });
+
+ const positions = new Float32Array(totalVertices * 3);
+ const normals = new Float32Array(totalVertices * 3);
+ const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
+ const indices = new Uint32Array(totalIndices);
+
+ let vertexOffset = 0;
+ let indexOffset = 0;
+
+ for (const geometryInfo of geometryInfos) {
+ for (
+ let vertexIndex = 0;
+ vertexIndex < geometryInfo.vertexCount;
+ vertexIndex += 1
+ ) {
+ const positionOffset = (vertexOffset + vertexIndex) * 3;
+ positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
+ positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
+ positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
+ normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
+ normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
+ normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
+
+ if (uvs !== null) {
+ const uvOffset = (vertexOffset + vertexIndex) * 2;
+ uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
+ uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
+ }
+ }
+
+ if (geometryInfo.index) {
+ for (
+ let localIndex = 0;
+ localIndex < geometryInfo.indexCount;
+ localIndex += 1
+ ) {
+ indices[indexOffset + localIndex] =
+ geometryInfo.index.getX(localIndex) + vertexOffset;
+ }
+ } else {
+ for (
+ let localIndex = 0;
+ localIndex < geometryInfo.indexCount;
+ localIndex += 1
+ ) {
+ indices[indexOffset + localIndex] = localIndex + vertexOffset;
+ }
+ }
+
+ vertexOffset += geometryInfo.vertexCount;
+ indexOffset += geometryInfo.indexCount;
+ }
+
+ const merged = new THREE.BufferGeometry();
+ merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+ merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
+
+ if (uvs !== null) {
+ merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
+ }
+
+ merged.setIndex(new THREE.BufferAttribute(indices, 1));
+
+ return merged;
+}
+
+function normalizeImportedGeometry(
+ geometry: THREE.BufferGeometry,
+ options: ImportedGeometryNormalizationOptions = {},
+) {
+ const {
+ postRotateZ = 0,
+ scaleTarget,
+ useLegacyNormalization = false,
+ } = options;
+
+ geometry.computeBoundingBox();
+
+ let boundingBox = geometry.boundingBox;
+ let center = new THREE.Vector3();
+ let size = new THREE.Vector3();
+
+ boundingBox?.getCenter(center);
+ boundingBox?.getSize(size);
+ geometry.translate(-center.x, -center.y, -center.z);
+
+ if (!useLegacyNormalization) {
+ const dimensions = [size.x, size.y, size.z];
+ const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
+
+ if (thinnestAxis === 0) {
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
+ } else if (thinnestAxis === 1) {
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
+ }
+ }
+
+ geometry.computeBoundingBox();
+ geometry.computeBoundingSphere();
+
+ const radius = geometry.boundingSphere?.radius || 1;
+ const scale = useLegacyNormalization
+ ? (scaleTarget ?? LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET) /
+ Math.max(size.x, size.y, size.z, 0.001)
+ : (scaleTarget ?? 1.6) / radius;
+ geometry.scale(scale, scale, scale);
+
+ geometry.computeBoundingBox();
+ boundingBox = geometry.boundingBox;
+ center = new THREE.Vector3();
+ boundingBox?.getCenter(center);
+ geometry.translate(-center.x, -center.y, -center.z);
+
+ if (postRotateZ !== 0) {
+ geometry.rotateZ(postRotateZ);
+ }
+
+ geometry.computeVertexNormals();
+ geometry.computeBoundingBox();
+ geometry.computeBoundingSphere();
+
+ return geometry;
+}
+
+function createLoadingManager() {
+ const loadingManager = new THREE.LoadingManager();
+ loadingManager.setURLModifier((url) =>
+ /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
+ );
+
+ return loadingManager;
+}
+
+function extractMergedGeometry(
+ root: THREE.Object3D,
+ emptyMessage: string,
+ geometryOptions?: ImportedGeometryNormalizationOptions,
+) {
+ root.updateMatrixWorld(true);
+
+ const geometries: THREE.BufferGeometry[] = [];
+
+ root.traverse((object) => {
+ if (!(object instanceof THREE.Mesh) || !object.geometry) {
+ return;
+ }
+
+ const geometry = object.geometry.clone();
+
+ if (!geometry.attributes.normal) {
+ geometry.computeVertexNormals();
+ }
+
+ geometry.applyMatrix4(object.matrixWorld);
+ geometries.push(geometry);
+ });
+
+ if (geometries.length === 0) {
+ throw new Error(emptyMessage);
+ }
+
+ return normalizeImportedGeometry(
+ mergeGeometries(geometries),
+ geometryOptions,
+ );
+}
+
+function parseFbxGeometry(
+ buffer: ArrayBuffer,
+ resourcePath: string,
+ label: string,
+ geometryOptions?: ImportedGeometryNormalizationOptions,
+) {
+ const originalWarn = console.warn;
+
+ console.warn = (...args: unknown[]) => {
+ if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) {
+ return;
+ }
+
+ originalWarn(...args);
+ };
+
+ try {
+ const root = new FBXLoader(createLoadingManager()).parse(
+ buffer,
+ resourcePath,
+ );
+
+ return extractMergedGeometry(
+ root,
+ `${label} did not contain any mesh geometry.`,
+ geometryOptions,
+ );
+ } finally {
+ console.warn = originalWarn;
+ }
+}
+
+function parseGlbGeometry(
+ buffer: ArrayBuffer,
+ resourcePath: string,
+ label: string,
+ geometryOptions?: ImportedGeometryNormalizationOptions,
+) {
+ const dracoLoader = new DRACOLoader();
+ dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
+
+ const gltfLoader = new GLTFLoader(createLoadingManager());
+ gltfLoader.setDRACOLoader(dracoLoader);
+
+ return new Promise((resolve, reject) => {
+ gltfLoader.parse(
+ buffer,
+ resourcePath,
+ (gltf) => {
+ try {
+ resolve(
+ extractMergedGeometry(
+ gltf.scene,
+ `${label} did not contain any mesh geometry.`,
+ geometryOptions,
+ ),
+ );
+ } catch (error) {
+ reject(error);
+ }
+ },
+ reject,
+ );
+ }).finally(() => {
+ dracoLoader.dispose();
+ });
+}
+
+export async function loadImportedGeometry(
+ loader: HalftoneModelLoader,
+ file: File,
+ label: string,
+) {
+ const buffer = await file.arrayBuffer();
+
+ if (loader === 'fbx') {
+ return parseFbxGeometry(buffer, '', label);
+ }
+
+ return parseGlbGeometry(buffer, '', label);
+}
+
+export async function loadImportedGeometryFromUrl(
+ loader: HalftoneModelLoader,
+ modelUrl: string,
+ label: string,
+ geometryOptions?: ImportedGeometryNormalizationOptions,
+) {
+ const cacheKey = getImportedGeometryCacheKey({
+ geometryOptions,
+ loader,
+ modelUrl,
+ });
+ const cachedGeometry = importedGeometryCache.get(cacheKey);
+
+ if (cachedGeometry) {
+ return (await cachedGeometry).clone();
+ }
+
+ const geometryPromise = (async () => {
+ const response = await fetch(modelUrl);
+
+ if (!response.ok) {
+ throw new Error(`Unable to load ${label} from ${modelUrl}.`);
+ }
+
+ const buffer = await response.arrayBuffer();
+
+ if (loader === 'fbx') {
+ return parseFbxGeometry(buffer, '', label, geometryOptions);
+ }
+
+ return parseGlbGeometry(buffer, '', label, geometryOptions);
+ })();
+ importedGeometryCache.set(cacheKey, geometryPromise);
+
+ try {
+ const geometry = await geometryPromise;
+
+ importedGeometryCache.set(cacheKey, geometry);
+
+ return geometry.clone();
+ } catch (error) {
+ importedGeometryCache.delete(cacheKey);
+ throw error;
+ }
+}
+
+type GeometryUrlCacheEntry =
+ | THREE.BufferGeometry
+ | Promise;
+
+const importedGeometryCache = new Map();
+
+function getImportedGeometryCacheKey({
+ geometryOptions,
+ loader,
+ modelUrl,
+}: {
+ geometryOptions?: ImportedGeometryNormalizationOptions;
+ loader: HalftoneModelLoader;
+ modelUrl: string;
+}) {
+ return JSON.stringify({
+ geometryOptions: geometryOptions ?? null,
+ loader,
+ modelUrl,
+ });
+}
diff --git a/packages/twenty-website-new/src/lib/halftone/utils/geometry-registry.ts b/packages/twenty-website-new/src/lib/halftone/utils/geometry-registry.ts
new file mode 100644
index 0000000000..4877ce2a5c
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/halftone/utils/geometry-registry.ts
@@ -0,0 +1,11 @@
+export {
+ type ImportedGeometryNormalizationOptions,
+ loadImportedGeometryFromUrl,
+} from './geometry-loaders';
+
+export {
+ type GeometryCacheEntry,
+ createFallbackGeometry,
+ disposeGeometryCache,
+ getGeometryForSpec,
+} from './geometry-cache';
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/glassEnvironmentData.ts b/packages/twenty-website-new/src/lib/halftone/utils/glass-environment-data.ts
similarity index 100%
rename from packages/twenty-website-new/src/app/[locale]/halftone/_lib/glassEnvironmentData.ts
rename to packages/twenty-website-new/src/lib/halftone/utils/glass-environment-data.ts
diff --git a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/imageSvgExport.ts b/packages/twenty-website-new/src/lib/halftone/utils/image-svg-export.ts
similarity index 98%
rename from packages/twenty-website-new/src/app/[locale]/halftone/_lib/imageSvgExport.ts
rename to packages/twenty-website-new/src/lib/halftone/utils/image-svg-export.ts
index 6018c1674d..e578d4ba62 100644
--- a/packages/twenty-website-new/src/app/[locale]/halftone/_lib/imageSvgExport.ts
+++ b/packages/twenty-website-new/src/lib/halftone/utils/image-svg-export.ts
@@ -2,8 +2,8 @@ import {
getContainedImageRect,
getImageFootprintScale,
getImagePreviewZoom,
-} from '@/lib/halftone/footprint';
-import type { HalftoneStudioSettings } from '@/lib/halftone/state';
+} from '@/lib/halftone/utils/footprint';
+import type { HalftoneStudioSettings } from '@/lib/halftone/utils/state';
type PixelBounds = {
maxX: number;
diff --git a/packages/twenty-website-new/src/lib/halftone/interaction-state.ts b/packages/twenty-website-new/src/lib/halftone/utils/interaction-state.ts
similarity index 74%
rename from packages/twenty-website-new/src/lib/halftone/interaction-state.ts
rename to packages/twenty-website-new/src/lib/halftone/utils/interaction-state.ts
index f65a72097c..e1d9812f5b 100644
--- a/packages/twenty-website-new/src/lib/halftone/interaction-state.ts
+++ b/packages/twenty-website-new/src/lib/halftone/utils/interaction-state.ts
@@ -1,7 +1,7 @@
import type {
HalftoneExportPose,
HalftoneStudioSettings,
-} from '@/lib/halftone/state';
+} from '@/lib/halftone/utils/state';
export type HalftoneInteractionState = {
activePointerId: number | null;
@@ -106,3 +106,26 @@ export const resetHalftoneInteractionState = (
interactionState.autoElapsed = 0;
}
};
+
+// Runtime JS string equivalents for standalone exported HTML.
+// Used by the exporter to avoid maintaining parallel inline copies.
+export const SPRING_STEP_RUNTIME_SOURCE = `
+function applySpringStep(current, target, velocity, strength, damping) {
+ const nextVelocity = (velocity + (target - current) * strength) * damping;
+ return { value: current + nextVelocity, velocity: nextVelocity };
+}`;
+
+export const RESET_INTERACTION_STATE_RUNTIME_SOURCE = `
+function resetInteractionState(interactionState) {
+ interactionState.dragging = false;
+ interactionState.mouseX = 0.5;
+ interactionState.mouseY = 0.5;
+ interactionState.targetRotationX = 0;
+ interactionState.targetRotationY = 0;
+ interactionState.velocityX = 0;
+ interactionState.velocityY = 0;
+ interactionState.rotationVelocityX = 0;
+ interactionState.rotationVelocityY = 0;
+ interactionState.rotationVelocityZ = 0;
+ interactionState.autoElapsed = 0;
+}`;
diff --git a/packages/twenty-website-new/src/lib/halftone/materials.ts b/packages/twenty-website-new/src/lib/halftone/utils/materials.ts
similarity index 99%
rename from packages/twenty-website-new/src/lib/halftone/materials.ts
rename to packages/twenty-website-new/src/lib/halftone/utils/materials.ts
index 7f37cc1c9b..12564d709d 100644
--- a/packages/twenty-website-new/src/lib/halftone/materials.ts
+++ b/packages/twenty-website-new/src/lib/halftone/utils/materials.ts
@@ -1,4 +1,4 @@
-import type { HalftoneMaterialSettings } from '@/lib/halftone/state';
+import type { HalftoneMaterialSettings } from '@/lib/halftone/utils/state';
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
import * as THREE from 'three';
diff --git a/packages/twenty-website-new/src/lib/halftone/state.ts b/packages/twenty-website-new/src/lib/halftone/utils/state.ts
similarity index 99%
rename from packages/twenty-website-new/src/lib/halftone/state.ts
rename to packages/twenty-website-new/src/lib/halftone/utils/state.ts
index 79c1b0a0e4..9bb48d09bd 100644
--- a/packages/twenty-website-new/src/lib/halftone/state.ts
+++ b/packages/twenty-website-new/src/lib/halftone/utils/state.ts
@@ -193,7 +193,7 @@ function upsertGeometrySpec(
);
}
-export const DEFAULT_GEOMETRY_SPECS: HalftoneGeometrySpec[] = [
+const DEFAULT_GEOMETRY_SPECS: HalftoneGeometrySpec[] = [
{ key: 'torusKnot', label: 'Torus Knot', kind: 'builtin' },
{ key: 'sphere', label: 'Sphere', kind: 'builtin' },
{ key: 'torus', label: 'Torus', kind: 'builtin' },
@@ -450,7 +450,7 @@ export function isRoundedBandHalftoneSettings(value: unknown): value is Omit<
);
}
-export function getDefaultHalftoneSettings(sourceMode: HalftoneSourceMode) {
+function getDefaultHalftoneSettings(sourceMode: HalftoneSourceMode) {
return sourceMode === 'image'
? DEFAULT_IMAGE_HALFTONE_SETTINGS
: DEFAULT_SHAPE_HALFTONE_SETTINGS;
diff --git a/packages/twenty-website-new/src/lib/i18n/__tests__/detect-locale.test.ts b/packages/twenty-website-new/src/lib/i18n/__tests__/detect-locale.test.ts
index 7224111afa..8af84c0c4c 100644
--- a/packages/twenty-website-new/src/lib/i18n/__tests__/detect-locale.test.ts
+++ b/packages/twenty-website-new/src/lib/i18n/__tests__/detect-locale.test.ts
@@ -1,4 +1,4 @@
-import { detectLocale } from '../detect-locale';
+import { detectLocale } from '../utils/detect-locale';
describe('detectLocale', () => {
it('returns the cookie value when it is a published website locale', () => {
diff --git a/packages/twenty-website-new/src/lib/i18n/__tests__/locale-display-names.test.ts b/packages/twenty-website-new/src/lib/i18n/__tests__/locale-display-names.test.ts
index fb82bb0612..a78daea203 100644
--- a/packages/twenty-website-new/src/lib/i18n/__tests__/locale-display-names.test.ts
+++ b/packages/twenty-website-new/src/lib/i18n/__tests__/locale-display-names.test.ts
@@ -1,7 +1,7 @@
import {
getEnglishLocaleName,
getNativeLocaleName,
-} from '../locale-display-names';
+} from '../utils/locale-display-names';
describe('getNativeLocaleName', () => {
it('returns the language name in its own language with the first character upper-cased', () => {
diff --git a/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts b/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts
index 3dcbd49a2d..52aae589a6 100644
--- a/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts
+++ b/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts
@@ -1,4 +1,4 @@
-import { localizeHref, stripLocale } from '../localize-href';
+import { localizeHref, stripLocale } from '../utils/localize-href';
describe('localizeHref', () => {
it('does not emit locale prefixes for locales the website does not publish yet', () => {
@@ -34,16 +34,12 @@ describe('localizeHref', () => {
});
it('strips a redundant /en prefix when targeting the default locale', () => {
- expect(localizeHref('en', '/en/resources/why-twenty')).toBe(
- '/resources/why-twenty',
- );
+ expect(localizeHref('en', '/en/why-twenty')).toBe('/why-twenty');
expect(localizeHref('en', '/en')).toBe('/');
});
it('strips an /en-prefixed path when targeting an unpublished locale', () => {
- expect(localizeHref('de-DE', '/en/resources/why-twenty')).toBe(
- '/resources/why-twenty',
- );
+ expect(localizeHref('de-DE', '/en/why-twenty')).toBe('/why-twenty');
expect(localizeHref('de-DE', '/en')).toBe('/');
});
@@ -85,9 +81,7 @@ describe('localizeHref', () => {
describe('stripLocale', () => {
it('removes a published-locale URL-segment prefix from the pathname', () => {
- expect(stripLocale('/en/resources/why-twenty')).toBe(
- '/resources/why-twenty',
- );
+ expect(stripLocale('/en/why-twenty')).toBe('/why-twenty');
expect(stripLocale('/fr/customers/9dots')).toBe('/customers/9dots');
});
@@ -97,7 +91,7 @@ describe('stripLocale', () => {
});
it('returns the pathname unchanged when no published locale prefix is present', () => {
- expect(stripLocale('/resources/why-twenty')).toBe('/resources/why-twenty');
+ expect(stripLocale('/why-twenty')).toBe('/why-twenty');
expect(stripLocale('/')).toBe('/');
expect(stripLocale('/fr-FR/foo')).toBe('/fr-FR/foo');
expect(stripLocale('/zh-CN')).toBe('/zh-CN');
diff --git a/packages/twenty-website-new/src/lib/i18n/I18nProvider.tsx b/packages/twenty-website-new/src/lib/i18n/components/I18nProvider.tsx
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/I18nProvider.tsx
rename to packages/twenty-website-new/src/lib/i18n/components/I18nProvider.tsx
diff --git a/packages/twenty-website-new/src/lib/i18n/LocaleContext.tsx b/packages/twenty-website-new/src/lib/i18n/components/LocaleContext.tsx
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/LocaleContext.tsx
rename to packages/twenty-website-new/src/lib/i18n/components/LocaleContext.tsx
diff --git a/packages/twenty-website-new/src/lib/i18n/LocalizedLink.tsx b/packages/twenty-website-new/src/lib/i18n/components/LocalizedLink.tsx
similarity index 80%
rename from packages/twenty-website-new/src/lib/i18n/LocalizedLink.tsx
rename to packages/twenty-website-new/src/lib/i18n/components/LocalizedLink.tsx
index 8d52b46a7e..aa774988b1 100644
--- a/packages/twenty-website-new/src/lib/i18n/LocalizedLink.tsx
+++ b/packages/twenty-website-new/src/lib/i18n/components/LocalizedLink.tsx
@@ -3,8 +3,8 @@
import Link from 'next/link';
import { type ComponentProps } from 'react';
-import { localizeHref } from './localize-href';
-import { useLocale } from './use-locale';
+import { localizeHref } from '../utils/localize-href';
+import { useLocale } from '../hooks/use-locale';
type LocalizedLinkProps = Omit, 'href'> & {
href: string;
diff --git a/packages/twenty-website-new/src/lib/i18n/LocalizedLinkButton.tsx b/packages/twenty-website-new/src/lib/i18n/components/LocalizedLinkButton.tsx
similarity index 87%
rename from packages/twenty-website-new/src/lib/i18n/LocalizedLinkButton.tsx
rename to packages/twenty-website-new/src/lib/i18n/components/LocalizedLinkButton.tsx
index 22f1753df9..fa52857a55 100644
--- a/packages/twenty-website-new/src/lib/i18n/LocalizedLinkButton.tsx
+++ b/packages/twenty-website-new/src/lib/i18n/components/LocalizedLinkButton.tsx
@@ -5,7 +5,7 @@ import {
type BaseButtonProps,
buttonBaseStyles,
} from '@/design-system/components/Button/BaseButton';
-import type { LinkButtonType } from '@/design-system/components/Button/types/LinkButtonType';
+import type { LinkButtonType } from '@/design-system/components/Button/types/link-button-type';
import { styled } from '@linaria/react';
import { LocalizedLink } from './LocalizedLink';
@@ -14,8 +14,7 @@ const StyledButtonLink = styled(LocalizedLink)`
${buttonBaseStyles}
`;
-export type LocalizedLinkButtonProps = Omit &
- LinkButtonType;
+type LocalizedLinkButtonProps = Omit & LinkButtonType;
export function LocalizedLinkButton({
color,
diff --git a/packages/twenty-website-new/src/lib/i18n/create-message-descriptor-renderer.ts b/packages/twenty-website-new/src/lib/i18n/create-message-descriptor-renderer.ts
deleted file mode 100644
index 3f10838227..0000000000
--- a/packages/twenty-website-new/src/lib/i18n/create-message-descriptor-renderer.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type { I18n, MessageDescriptor } from '@lingui/core';
-
-export const createMessageDescriptorRenderer =
- (i18n: I18n) =>
- (descriptor: MessageDescriptor): string =>
- i18n._(descriptor);
diff --git a/packages/twenty-website-new/src/lib/i18n/use-locale.ts b/packages/twenty-website-new/src/lib/i18n/hooks/use-locale.ts
similarity index 86%
rename from packages/twenty-website-new/src/lib/i18n/use-locale.ts
rename to packages/twenty-website-new/src/lib/i18n/hooks/use-locale.ts
index 81d16034a7..8b90030293 100644
--- a/packages/twenty-website-new/src/lib/i18n/use-locale.ts
+++ b/packages/twenty-website-new/src/lib/i18n/hooks/use-locale.ts
@@ -3,7 +3,7 @@
import { useContext } from 'react';
import { type AppLocale } from 'twenty-shared/translations';
-import { LocaleContext } from './LocaleContext';
+import { LocaleContext } from '../components/LocaleContext';
export const useLocale = (): AppLocale => {
const locale = useContext(LocaleContext);
diff --git a/packages/twenty-website-new/src/lib/i18n/use-unlocalized-pathname.ts b/packages/twenty-website-new/src/lib/i18n/hooks/use-unlocalized-pathname.ts
similarity index 78%
rename from packages/twenty-website-new/src/lib/i18n/use-unlocalized-pathname.ts
rename to packages/twenty-website-new/src/lib/i18n/hooks/use-unlocalized-pathname.ts
index a1a27d3afb..363da47d41 100644
--- a/packages/twenty-website-new/src/lib/i18n/use-unlocalized-pathname.ts
+++ b/packages/twenty-website-new/src/lib/i18n/hooks/use-unlocalized-pathname.ts
@@ -2,7 +2,7 @@
import { usePathname } from 'next/navigation';
-import { stripLocale } from './localize-href';
+import { stripLocale } from '../utils/localize-href';
export const useUnlocalizedPathname = (): string => {
const pathname = usePathname();
diff --git a/packages/twenty-website-new/src/lib/i18n/index.ts b/packages/twenty-website-new/src/lib/i18n/index.ts
index b614310740..f049594737 100644
--- a/packages/twenty-website-new/src/lib/i18n/index.ts
+++ b/packages/twenty-website-new/src/lib/i18n/index.ts
@@ -7,25 +7,24 @@ export {
PUBLIC_APP_LOCALE_LIST,
WEBSITE_LOCALE_LIST,
isPublicAppLocale,
-} from './app-locale-set';
-export { createI18nInstance } from './create-i18n-instance';
-export { createMessageDescriptorRenderer } from './create-message-descriptor-renderer';
-export { detectLocale, LOCALE_COOKIE_NAME } from './detect-locale';
-export { getRouteI18n, type LocaleRouteParams } from './get-route-i18n';
-export { I18nProvider } from './I18nProvider';
-export { LocaleContext } from './LocaleContext';
-export { LocalizedLink } from './LocalizedLink';
-export { LocalizedLinkButton } from './LocalizedLinkButton';
-export { localizeHref, stripLocale } from './localize-href';
+} from './utils/app-locale-set';
+export { createI18nInstance } from './utils/create-i18n-instance';
+export { getServerI18n } from './utils/get-server-i18n';
+export { detectLocale, LOCALE_COOKIE_NAME } from './utils/detect-locale';
+export { getRouteI18n, type LocaleRouteParams } from './utils/get-route-i18n';
+export { I18nProvider } from './components/I18nProvider';
+export { LocaleContext } from './components/LocaleContext';
+export { LocalizedLink } from './components/LocalizedLink';
+export { LocalizedLinkButton } from './components/LocalizedLinkButton';
+export { localizeHref, stripLocale } from './utils/localize-href';
export {
getEnglishLocaleName,
getNativeLocaleName,
-} from './locale-display-names';
-export { resolveLocaleParam } from './resolve-locale-param';
+} from './utils/locale-display-names';
+export { resolveLocaleParam } from './utils/resolve-locale-param';
export {
LOCALE_BY_URL_SEGMENT,
localeToUrlSegment,
-} from './website-locale-segments';
-export { useLocale } from './use-locale';
-export { useRenderMessage } from './use-render-message';
-export { useUnlocalizedPathname } from './use-unlocalized-pathname';
+} from './utils/website-locale-segments';
+export { useLocale } from './hooks/use-locale';
+export { useUnlocalizedPathname } from './hooks/use-unlocalized-pathname';
diff --git a/packages/twenty-website-new/src/lib/i18n/message-body.ts b/packages/twenty-website-new/src/lib/i18n/message-body.ts
deleted file mode 100644
index f85cbc1817..0000000000
--- a/packages/twenty-website-new/src/lib/i18n/message-body.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import type { MessageDescriptor } from '@lingui/core';
-
-export type MessageBody = {
- text: MessageDescriptor;
-};
diff --git a/packages/twenty-website-new/src/lib/i18n/message-eyebrow.ts b/packages/twenty-website-new/src/lib/i18n/message-eyebrow.ts
deleted file mode 100644
index b21c7b546f..0000000000
--- a/packages/twenty-website-new/src/lib/i18n/message-eyebrow.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import type { MessageHeadingSegment } from './message-heading-segment';
-
-export type MessageEyebrow = {
- heading: MessageHeadingSegment;
-};
diff --git a/packages/twenty-website-new/src/lib/i18n/message-heading-segment.ts b/packages/twenty-website-new/src/lib/i18n/message-heading-segment.ts
deleted file mode 100644
index 670d4a3d70..0000000000
--- a/packages/twenty-website-new/src/lib/i18n/message-heading-segment.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import type { MessageDescriptor } from '@lingui/core';
-
-export type MessageHeadingSegment = {
- fontFamily: 'sans' | 'serif' | 'mono';
- text: MessageDescriptor;
- fontWeight?: 'light' | 'regular' | 'medium';
- newLine?: boolean;
- lineBreakBefore?: boolean;
-};
diff --git a/packages/twenty-website-new/src/lib/i18n/use-render-message.ts b/packages/twenty-website-new/src/lib/i18n/use-render-message.ts
deleted file mode 100644
index a51c5de424..0000000000
--- a/packages/twenty-website-new/src/lib/i18n/use-render-message.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-'use client';
-
-import type { MessageDescriptor } from '@lingui/core';
-import { useLingui } from '@lingui/react';
-import { useCallback } from 'react';
-
-export const useRenderMessage = () => {
- const { i18n } = useLingui();
- return useCallback(
- (descriptor: MessageDescriptor) => i18n._(descriptor),
- [i18n],
- );
-};
diff --git a/packages/twenty-website-new/src/lib/i18n/app-locale-set.ts b/packages/twenty-website-new/src/lib/i18n/utils/app-locale-set.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/app-locale-set.ts
rename to packages/twenty-website-new/src/lib/i18n/utils/app-locale-set.ts
diff --git a/packages/twenty-website-new/src/lib/i18n/create-i18n-instance.ts b/packages/twenty-website-new/src/lib/i18n/utils/create-i18n-instance.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/create-i18n-instance.ts
rename to packages/twenty-website-new/src/lib/i18n/utils/create-i18n-instance.ts
diff --git a/packages/twenty-website-new/src/lib/i18n/detect-locale.ts b/packages/twenty-website-new/src/lib/i18n/utils/detect-locale.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/detect-locale.ts
rename to packages/twenty-website-new/src/lib/i18n/utils/detect-locale.ts
diff --git a/packages/twenty-website-new/src/lib/i18n/get-message-descriptor-source.ts b/packages/twenty-website-new/src/lib/i18n/utils/get-message-descriptor-source.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/get-message-descriptor-source.ts
rename to packages/twenty-website-new/src/lib/i18n/utils/get-message-descriptor-source.ts
diff --git a/packages/twenty-website-new/src/lib/i18n/get-route-i18n.ts b/packages/twenty-website-new/src/lib/i18n/utils/get-route-i18n.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/get-route-i18n.ts
rename to packages/twenty-website-new/src/lib/i18n/utils/get-route-i18n.ts
diff --git a/packages/twenty-website-new/src/lib/i18n/utils/get-server-i18n.ts b/packages/twenty-website-new/src/lib/i18n/utils/get-server-i18n.ts
new file mode 100644
index 0000000000..bab33d5a15
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/i18n/utils/get-server-i18n.ts
@@ -0,0 +1,13 @@
+import type { I18n } from '@lingui/core';
+import { getI18n } from '@lingui/react/server';
+
+import { createI18nInstance } from './create-i18n-instance';
+
+let fallback: I18n | null = null;
+
+export function getServerI18n(): I18n {
+ const instance = getI18n() as I18n | null;
+ if (instance) return instance;
+ if (!fallback) fallback = createI18nInstance('en');
+ return fallback;
+}
diff --git a/packages/twenty-website-new/src/lib/i18n/locale-display-names.ts b/packages/twenty-website-new/src/lib/i18n/utils/locale-display-names.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/locale-display-names.ts
rename to packages/twenty-website-new/src/lib/i18n/utils/locale-display-names.ts
diff --git a/packages/twenty-website-new/src/lib/i18n/localize-href.ts b/packages/twenty-website-new/src/lib/i18n/utils/localize-href.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/localize-href.ts
rename to packages/twenty-website-new/src/lib/i18n/utils/localize-href.ts
diff --git a/packages/twenty-website-new/src/lib/i18n/messages-by-locale.ts b/packages/twenty-website-new/src/lib/i18n/utils/messages-by-locale.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/messages-by-locale.ts
rename to packages/twenty-website-new/src/lib/i18n/utils/messages-by-locale.ts
diff --git a/packages/twenty-website-new/src/lib/i18n/resolve-locale-param.ts b/packages/twenty-website-new/src/lib/i18n/utils/resolve-locale-param.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/resolve-locale-param.ts
rename to packages/twenty-website-new/src/lib/i18n/utils/resolve-locale-param.ts
diff --git a/packages/twenty-website-new/src/lib/i18n/set-server-i18n.ts b/packages/twenty-website-new/src/lib/i18n/utils/set-server-i18n.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/set-server-i18n.ts
rename to packages/twenty-website-new/src/lib/i18n/utils/set-server-i18n.ts
diff --git a/packages/twenty-website-new/src/lib/i18n/website-locale-list.ts b/packages/twenty-website-new/src/lib/i18n/utils/website-locale-list.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/website-locale-list.ts
rename to packages/twenty-website-new/src/lib/i18n/utils/website-locale-list.ts
diff --git a/packages/twenty-website-new/src/lib/i18n/website-locale-segments.ts b/packages/twenty-website-new/src/lib/i18n/utils/website-locale-segments.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/i18n/website-locale-segments.ts
rename to packages/twenty-website-new/src/lib/i18n/utils/website-locale-segments.ts
diff --git a/packages/twenty-website-new/src/lib/lifecycle/__tests__/run-cleanup-tasks.test.ts b/packages/twenty-website-new/src/lib/react/__tests__/run-cleanup-tasks.test.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/lifecycle/__tests__/run-cleanup-tasks.test.ts
rename to packages/twenty-website-new/src/lib/react/__tests__/run-cleanup-tasks.test.ts
diff --git a/packages/twenty-website-new/src/lib/lifecycle/__tests__/timeout-registry.test.ts b/packages/twenty-website-new/src/lib/react/__tests__/timeout-registry.test.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/lifecycle/__tests__/timeout-registry.test.ts
rename to packages/twenty-website-new/src/lib/react/__tests__/timeout-registry.test.ts
diff --git a/packages/twenty-website-new/src/lib/lifecycle/run-cleanup-tasks.ts b/packages/twenty-website-new/src/lib/react/run-cleanup-tasks.ts
similarity index 73%
rename from packages/twenty-website-new/src/lib/lifecycle/run-cleanup-tasks.ts
rename to packages/twenty-website-new/src/lib/react/run-cleanup-tasks.ts
index c848d7d649..c1664a0e30 100644
--- a/packages/twenty-website-new/src/lib/lifecycle/run-cleanup-tasks.ts
+++ b/packages/twenty-website-new/src/lib/react/run-cleanup-tasks.ts
@@ -1,6 +1,6 @@
-export type CleanupErrorHandler = (error: unknown) => void;
+type CleanupErrorHandler = (error: unknown) => void;
-export const reportCleanupErrorInDevelopment: CleanupErrorHandler = (error) => {
+const reportCleanupErrorInDevelopment: CleanupErrorHandler = (error) => {
if (process.env.NODE_ENV !== 'production') {
console.error('Cleanup task failed:', error);
}
diff --git a/packages/twenty-website-new/src/lib/lifecycle/timeout-registry.ts b/packages/twenty-website-new/src/lib/react/timeout-registry.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/lifecycle/timeout-registry.ts
rename to packages/twenty-website-new/src/lib/react/timeout-registry.ts
diff --git a/packages/twenty-website-new/src/lib/react/use-timeout-registry.ts b/packages/twenty-website-new/src/lib/react/use-timeout-registry.ts
index 18e3dfa259..361ef79d0d 100644
--- a/packages/twenty-website-new/src/lib/react/use-timeout-registry.ts
+++ b/packages/twenty-website-new/src/lib/react/use-timeout-registry.ts
@@ -1,7 +1,7 @@
import {
createTimeoutRegistry,
type TimeoutRegistry,
-} from '@/lib/lifecycle/timeout-registry';
+} from '@/lib/react/timeout-registry';
import { useEffect, useRef } from 'react';
export function useTimeoutRegistry(): TimeoutRegistry {
diff --git a/packages/twenty-website-new/src/lib/semver/__tests__/compare-semantic-versions.test.ts b/packages/twenty-website-new/src/lib/releases/__tests__/compare-semantic-versions.test.ts
similarity index 96%
rename from packages/twenty-website-new/src/lib/semver/__tests__/compare-semantic-versions.test.ts
rename to packages/twenty-website-new/src/lib/releases/__tests__/compare-semantic-versions.test.ts
index 1669545ff9..27ab267aa9 100644
--- a/packages/twenty-website-new/src/lib/semver/__tests__/compare-semantic-versions.test.ts
+++ b/packages/twenty-website-new/src/lib/releases/__tests__/compare-semantic-versions.test.ts
@@ -1,4 +1,4 @@
-import { compareSemanticVersions } from '@/lib/semver/compare-semantic-versions';
+import { compareSemanticVersions } from '@/lib/releases/compare-semantic-versions';
function expectOrdered(versions: string[]) {
for (let i = 0; i < versions.length - 1; i++) {
diff --git a/packages/twenty-website-new/src/lib/semver/compare-semantic-versions.ts b/packages/twenty-website-new/src/lib/releases/compare-semantic-versions.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/semver/compare-semantic-versions.ts
rename to packages/twenty-website-new/src/lib/releases/compare-semantic-versions.ts
diff --git a/packages/twenty-website-new/src/lib/semver/get-formatted-release-number.ts b/packages/twenty-website-new/src/lib/releases/get-formatted-release-number.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/semver/get-formatted-release-number.ts
rename to packages/twenty-website-new/src/lib/releases/get-formatted-release-number.ts
diff --git a/packages/twenty-website-new/src/lib/releases/get-visible-releases.ts b/packages/twenty-website-new/src/lib/releases/get-visible-releases.ts
index 16fbf6650a..0b865ba303 100644
--- a/packages/twenty-website-new/src/lib/releases/get-visible-releases.ts
+++ b/packages/twenty-website-new/src/lib/releases/get-visible-releases.ts
@@ -1,5 +1,5 @@
import type { LocalReleaseNote } from '@/lib/releases/types';
-import { getFormattedReleaseNumber } from '@/lib/semver/get-formatted-release-number';
+import { getFormattedReleaseNumber } from '@/lib/releases/get-formatted-release-number';
function safeReleaseNumber(version: string): number | null {
try {
diff --git a/packages/twenty-website-new/src/lib/releases/load-local-release-notes.ts b/packages/twenty-website-new/src/lib/releases/load-local-release-notes.ts
index 333e6a3190..84cd9bb97e 100644
--- a/packages/twenty-website-new/src/lib/releases/load-local-release-notes.ts
+++ b/packages/twenty-website-new/src/lib/releases/load-local-release-notes.ts
@@ -4,7 +4,7 @@ import path from 'path';
import matter from 'gray-matter';
import type { LocalReleaseNote } from '@/lib/releases/types';
-import { compareSemanticVersions } from '@/lib/semver/compare-semantic-versions';
+import { compareSemanticVersions } from '@/lib/releases/compare-semantic-versions';
function normalizeFrontmatterDate(dateValue: unknown): string {
if (typeof dateValue === 'string') {
diff --git a/packages/twenty-website-new/src/lib/scroll/scroll-progress-effect.tsx b/packages/twenty-website-new/src/lib/scroll/ScrollProgressEffect.tsx
similarity index 100%
rename from packages/twenty-website-new/src/lib/scroll/scroll-progress-effect.tsx
rename to packages/twenty-website-new/src/lib/scroll/ScrollProgressEffect.tsx
diff --git a/packages/twenty-website-new/src/lib/scroll/index.ts b/packages/twenty-website-new/src/lib/scroll/index.ts
index 17f104e1a8..c819e00fa4 100644
--- a/packages/twenty-website-new/src/lib/scroll/index.ts
+++ b/packages/twenty-website-new/src/lib/scroll/index.ts
@@ -1,4 +1,2 @@
-export { computeScrollProgress } from './compute-scroll-progress';
-export { ScrollProgressEffect } from './scroll-progress-effect';
+export { ScrollProgressEffect } from './ScrollProgressEffect';
export { useScheduledOnScroll } from './use-scheduled-on-scroll';
-export { useScrollProgress } from './use-scroll-progress';
diff --git a/packages/twenty-website-new/src/lib/scroll/use-scroll-progress.ts b/packages/twenty-website-new/src/lib/scroll/use-scroll-progress.ts
index 883d098d97..6e7679d49b 100644
--- a/packages/twenty-website-new/src/lib/scroll/use-scroll-progress.ts
+++ b/packages/twenty-website-new/src/lib/scroll/use-scroll-progress.ts
@@ -2,6 +2,7 @@
import { useEffect, type RefObject } from 'react';
+import { createAnimationFrameLoop } from '@/lib/animation';
import { computeScrollProgress } from './compute-scroll-progress';
type UseScrollProgressOptions = {
@@ -20,7 +21,7 @@ export function useScrollProgress(
return;
}
- const handleScroll = () => {
+ const update = () => {
const element = scrollContainerRef.current;
if (!element) {
return;
@@ -37,12 +38,22 @@ export function useScrollProgress(
onProgress(progress);
};
- handleScroll();
- window.addEventListener('scroll', handleScroll, { passive: true });
- window.addEventListener('resize', handleScroll);
+ const scrollTask = createAnimationFrameLoop({
+ onFrame: () => {
+ update();
+ return false;
+ },
+ });
+
+ const schedule = scrollTask.start;
+
+ update();
+ window.addEventListener('scroll', schedule, { passive: true });
+ window.addEventListener('resize', schedule);
return () => {
- window.removeEventListener('scroll', handleScroll);
- window.removeEventListener('resize', handleScroll);
+ window.removeEventListener('scroll', schedule);
+ window.removeEventListener('resize', schedule);
+ scrollTask.stop();
};
}, [enabled, onProgress, scrollContainerRef]);
}
diff --git a/packages/twenty-website-new/src/lib/seo/JsonLd.tsx b/packages/twenty-website-new/src/lib/seo/JsonLd.tsx
new file mode 100644
index 0000000000..c26817204d
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/seo/JsonLd.tsx
@@ -0,0 +1,18 @@
+type JsonLdPrimitive = boolean | number | string | null;
+
+export type JsonLdValue =
+ | JsonLdPrimitive
+ | JsonLdValue[]
+ | { [key: string]: JsonLdValue | undefined };
+
+const serializeJsonLd = (data: JsonLdValue): string =>
+ JSON.stringify(data).replace(/
+ );
+}
diff --git a/packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx b/packages/twenty-website-new/src/lib/seo/__tests__/JsonLd.test.tsx
similarity index 93%
rename from packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx
rename to packages/twenty-website-new/src/lib/seo/__tests__/JsonLd.test.tsx
index f89dcf4509..be2b14c9fe 100644
--- a/packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx
+++ b/packages/twenty-website-new/src/lib/seo/__tests__/JsonLd.test.tsx
@@ -54,12 +54,12 @@ describe('buildFaqPageJsonLd', () => {
it('produces a FAQPage with one Question per input, each carrying an Answer', () => {
const questions = [
{
- question: { fontFamily: 'sans' as const, text: descriptor('Q1?') },
- answer: { text: descriptor('A1.') },
+ question: descriptor('Q1?'),
+ answer: descriptor('A1.'),
},
{
- question: { fontFamily: 'sans' as const, text: descriptor('Q2?') },
- answer: { text: descriptor('A2.') },
+ question: descriptor('Q2?'),
+ answer: descriptor('A2.'),
},
];
@@ -85,10 +85,8 @@ describe('buildFaqPageJsonLd', () => {
const data = buildFaqPageJsonLd(
[
{
- question: {
- text: { id: 'faq.q', message: 'Resolved question' },
- },
- answer: { text: { id: 'faq.a', message: 'Resolved answer' } },
+ question: { id: 'faq.q', message: 'Resolved question' },
+ answer: { id: 'faq.a', message: 'Resolved answer' },
},
],
renderText,
diff --git a/packages/twenty-website-new/src/lib/seo/build-localized-metadata.ts b/packages/twenty-website-new/src/lib/seo/build-localized-metadata.ts
index 67df59cb2b..1db487b790 100644
--- a/packages/twenty-website-new/src/lib/seo/build-localized-metadata.ts
+++ b/packages/twenty-website-new/src/lib/seo/build-localized-metadata.ts
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
-import { resolveLocaleParam } from '@/lib/i18n/resolve-locale-param';
-import { setServerI18n } from '@/lib/i18n/set-server-i18n';
+import { resolveLocaleParam } from '@/lib/i18n/utils/resolve-locale-param';
+import { setServerI18n } from '@/lib/i18n/utils/set-server-i18n';
import {
buildPageMetadata,
diff --git a/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts b/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts
index acc00e3aca..c34305b7d3 100644
--- a/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts
+++ b/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts
@@ -4,16 +4,16 @@ import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import {
PUBLIC_APP_LOCALE_LIST,
isPublicAppLocale,
-} from '@/lib/i18n/app-locale-set';
-import { createI18nInstance } from '@/lib/i18n/create-i18n-instance';
-import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
-import { localeToUrlSegment } from '@/lib/i18n/website-locale-segments';
+} from '@/lib/i18n/utils/app-locale-set';
+import { createI18nInstance } from '@/lib/i18n/utils/create-i18n-instance';
+import { localeToUrlSegment } from '@/lib/i18n/utils/website-locale-segments';
import type { MessageDescriptor } from '@lingui/core';
import { getSiteUrl } from './site-url';
const SITE_NAME = 'Twenty';
const TWITTER_HANDLE = '@twentycrm';
+const DEFAULT_OG_IMAGE_PATH = '/images/og/default.png';
export type BuildPageMetadataInput = {
locale: AppLocale;
@@ -66,20 +66,17 @@ export function buildPageMetadata({
const metadataLocale = isPublicAppLocale(locale) ? locale : SOURCE_LOCALE;
const canonical = localizePath(metadataLocale, normalizedPath);
const i18n = createI18nInstance(metadataLocale);
- const renderText = createMessageDescriptorRenderer(i18n);
- const resolvedTitle = renderText(title);
- const resolvedDescription = renderText(description);
+ const resolvedTitle = i18n._(title);
+ const resolvedDescription = i18n._(description);
- const ogImages =
- ogImage === undefined
- ? undefined
- : [
- {
- url: /^https?:\/\//i.test(ogImage)
- ? ogImage
- : `${siteUrl}${ogImage.startsWith('/') ? ogImage : `/${ogImage}`}`,
- },
- ];
+ const resolvedOgImage = ogImage ?? DEFAULT_OG_IMAGE_PATH;
+ const ogImages = [
+ {
+ url: /^https?:\/\//i.test(resolvedOgImage)
+ ? resolvedOgImage
+ : `${siteUrl}${resolvedOgImage.startsWith('/') ? resolvedOgImage : `/${resolvedOgImage}`}`,
+ },
+ ];
const baseMetadata: Metadata = {
title: { absolute: resolvedTitle },
@@ -95,7 +92,7 @@ export function buildPageMetadata({
siteName: SITE_NAME,
locale: metadataLocale,
type,
- ...(ogImages && { images: ogImages }),
+ images: ogImages,
},
twitter: {
card: 'summary_large_image',
@@ -103,7 +100,7 @@ export function buildPageMetadata({
description: resolvedDescription,
site: TWITTER_HANDLE,
creator: TWITTER_HANDLE,
- ...(ogImages && { images: ogImages.map((image) => image.url) }),
+ images: ogImages.map((image) => image.url),
},
};
diff --git a/packages/twenty-website-new/src/lib/seo/index.ts b/packages/twenty-website-new/src/lib/seo/index.ts
index e3c863a8a5..a062929cbe 100644
--- a/packages/twenty-website-new/src/lib/seo/index.ts
+++ b/packages/twenty-website-new/src/lib/seo/index.ts
@@ -1,16 +1,14 @@
export { buildLocalizedMetadata } from './build-localized-metadata';
-export {
- buildPageMetadata,
- type BuildPageMetadataInput,
-} from './build-page-metadata';
+export { buildPageMetadata } from './build-page-metadata';
export { buildRouteMetadata } from './build-route-metadata';
export {
- buildArticleListJsonLd,
buildArticleJsonLd,
+ buildArticleListJsonLd,
+ buildBreadcrumbListJsonLd,
buildFaqPageJsonLd,
buildOrganizationJsonLd,
buildReleaseListJsonLd,
buildSoftwareApplicationJsonLd,
- JsonLd,
-} from './json-ld';
-export { getAbsoluteUrl, getSiteUrl } from './site-url';
+} from './json-ld-builders';
+export { JsonLd } from './JsonLd';
+export { getSiteUrl } from './site-url';
diff --git a/packages/twenty-website-new/src/lib/seo/json-ld.tsx b/packages/twenty-website-new/src/lib/seo/json-ld-builders.ts
similarity index 82%
rename from packages/twenty-website-new/src/lib/seo/json-ld.tsx
rename to packages/twenty-website-new/src/lib/seo/json-ld-builders.ts
index 2c01a89341..c22b4f495f 100644
--- a/packages/twenty-website-new/src/lib/seo/json-ld.tsx
+++ b/packages/twenty-website-new/src/lib/seo/json-ld-builders.ts
@@ -1,33 +1,24 @@
import type { MessageDescriptor } from '@lingui/core';
+import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import type { Article } from '@/lib/articles';
+import { localeToUrlSegment } from '@/lib/i18n/utils/website-locale-segments';
import type { LocalReleaseNote } from '@/lib/releases/types';
+import type { JsonLdValue } from './JsonLd';
import { getSiteUrl } from './site-url';
type FaqEntryLike = {
- question: { text: MessageDescriptor };
- answer: { text: MessageDescriptor };
+ question: MessageDescriptor;
+ answer: MessageDescriptor;
};
-type JsonLdPrimitive = boolean | number | string | null;
+type RenderText = (descriptor: MessageDescriptor) => string;
-export type JsonLdValue =
- | JsonLdPrimitive
- | JsonLdValue[]
- | { [key: string]: JsonLdValue | undefined };
-
-const serializeJsonLd = (data: JsonLdValue): string =>
- JSON.stringify(data).replace(/
- );
-}
+type BreadcrumbItem = {
+ name: string;
+ path: string;
+};
export const buildOrganizationJsonLd = (): JsonLdValue => {
const siteUrl = getSiteUrl();
@@ -72,8 +63,6 @@ export const buildSoftwareApplicationJsonLd = (): JsonLdValue => {
};
};
-type RenderText = (descriptor: MessageDescriptor) => string;
-
export const buildFaqPageJsonLd = (
questions: readonly FaqEntryLike[],
renderText: RenderText,
@@ -82,10 +71,10 @@ export const buildFaqPageJsonLd = (
'@type': 'FAQPage',
mainEntity: questions.map((question) => ({
'@type': 'Question',
- name: renderText(question.question.text),
+ name: renderText(question.question),
acceptedAnswer: {
'@type': 'Answer',
- text: renderText(question.answer.text),
+ text: renderText(question.answer),
},
})),
});
@@ -190,3 +179,23 @@ export const buildArticleJsonLd = (post: Article): JsonLdValue => {
},
};
};
+
+export const buildBreadcrumbListJsonLd = (
+ items: readonly BreadcrumbItem[],
+ locale?: AppLocale,
+): JsonLdValue => {
+ const siteUrl = getSiteUrl();
+ const prefix =
+ locale && locale !== SOURCE_LOCALE ? `/${localeToUrlSegment(locale)}` : '';
+
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: items.map((item, index) => ({
+ '@type': 'ListItem',
+ position: index + 1,
+ name: item.name,
+ item: `${siteUrl}${prefix}${item.path}`,
+ })),
+ };
+};
diff --git a/packages/twenty-website-new/src/lib/seo/site-url.ts b/packages/twenty-website-new/src/lib/seo/site-url.ts
index 4c367d754d..70153511f5 100644
--- a/packages/twenty-website-new/src/lib/seo/site-url.ts
+++ b/packages/twenty-website-new/src/lib/seo/site-url.ts
@@ -13,12 +13,3 @@ export function getSiteUrl(): string {
return DEFAULT_SITE_URL;
}
-
-export function getAbsoluteUrl(pathOrUrl: string): string {
- if (/^https?:\/\//i.test(pathOrUrl)) {
- return pathOrUrl;
- }
-
- const path = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
- return `${getSiteUrl()}${path}`;
-}
diff --git a/packages/twenty-website-new/src/lib/stepper/index.ts b/packages/twenty-website-new/src/lib/stepper/index.ts
deleted file mode 100644
index 2a62a8c627..0000000000
--- a/packages/twenty-website-new/src/lib/stepper/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { StepperSwipeDeck } from './stepper-swipe-deck';
-export { getStepperMdUpSnapshot, useStepperMdUp } from './use-stepper-md-up';
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/bounded-failure-cache.test.ts b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/bounded-failure-cache.test.ts
index ecdb89d92d..e7c86af94f 100644
--- a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/bounded-failure-cache.test.ts
+++ b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/bounded-failure-cache.test.ts
@@ -1,4 +1,4 @@
-import { createBoundedFailureCache } from '@/lib/visual-runtime/bounded-failure-cache';
+import { createBoundedFailureCache } from '@/lib/visual-runtime/utils/bounded-failure-cache';
describe('createBoundedFailureCache', () => {
it('records added URLs and reports them via has()', () => {
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-mount-scheduler.test.ts b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-mount-scheduler.test.ts
index 8dc865dd9b..58c71ab503 100644
--- a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-mount-scheduler.test.ts
+++ b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-mount-scheduler.test.ts
@@ -1,4 +1,4 @@
-import { createVisualMountScheduler } from '../visual-mount-scheduler';
+import { createVisualMountScheduler } from '../utils/visual-mount-scheduler';
function createManualIdleHost() {
let nextHandle = 1;
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts
index 2930691747..580543505c 100644
--- a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts
+++ b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts
@@ -1,7 +1,7 @@
import {
createVisualRenderLoop,
type VisualRenderLoopDocument,
-} from '../visual-render-loop';
+} from '../utils/visual-render-loop';
function createAnimationFrameScheduler() {
let nextHandle = 1;
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/webgl-error-boundary.tsx b/packages/twenty-website-new/src/lib/visual-runtime/components/WebglErrorBoundary.tsx
similarity index 92%
rename from packages/twenty-website-new/src/lib/visual-runtime/webgl-error-boundary.tsx
rename to packages/twenty-website-new/src/lib/visual-runtime/components/WebglErrorBoundary.tsx
index f4fc471217..fa9630098e 100644
--- a/packages/twenty-website-new/src/lib/visual-runtime/webgl-error-boundary.tsx
+++ b/packages/twenty-website-new/src/lib/visual-runtime/components/WebglErrorBoundary.tsx
@@ -2,7 +2,7 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
-import { WebGlUnavailableError } from './visual-runtime-policy';
+import { WebGlUnavailableError } from '../utils/visual-runtime-policy';
type WebGlErrorBoundaryProps = {
children: ReactNode;
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx b/packages/twenty-website-new/src/lib/visual-runtime/components/WebglMount.tsx
similarity index 94%
rename from packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx
rename to packages/twenty-website-new/src/lib/visual-runtime/components/WebglMount.tsx
index 832aa77ab3..c054bc3b8e 100644
--- a/packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx
+++ b/packages/twenty-website-new/src/lib/visual-runtime/components/WebglMount.tsx
@@ -8,14 +8,14 @@ import { observeElementVisibility } from '@/lib/dom/observe-element-visibility';
import {
subscribeToActiveWebGlContextCount,
tryReserveWebGlContextSlot,
-} from './active-webgl-context-budget';
-import { SITE_WEBGL_CONTEXT_LOST_EVENT } from './create-site-webgl-renderer';
+} from '../utils/active-webgl-context-budget';
+import { SITE_WEBGL_CONTEXT_LOST_EVENT } from '../utils/create-site-webgl-renderer';
import {
scheduleVisualMount,
type VisualMountPriority,
-} from './visual-mount-scheduler';
-import { useWebGlPolicy } from './use-webgl-policy';
-import { WebGlErrorBoundary } from './webgl-error-boundary';
+} from '../utils/visual-mount-scheduler';
+import { useWebGlPolicy } from '../hooks/use-webgl-policy';
+import { WebGlErrorBoundary } from './WebglErrorBoundary';
const NON_PRIORITY_ROOT_MARGIN = '50% 0px 50% 0px';
const PRIORITY_ROOT_MARGIN = '125% 0px 125% 0px';
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-geometry.ts b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-geometry.ts
new file mode 100644
index 0000000000..18c4c31a89
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-geometry.ts
@@ -0,0 +1,12 @@
+import type * as THREE from 'three';
+
+import { useAsyncResource } from './use-async-resource';
+
+const DISPOSE_GEOMETRY = (geometry: THREE.BufferGeometry) => geometry.dispose();
+
+export function useAsyncGeometry(
+ loader: (() => Promise) | null,
+ deps: readonly unknown[],
+) {
+ return useAsyncResource(loader, deps, { dispose: DISPOSE_GEOMETRY });
+}
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-image.ts b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-image.ts
new file mode 100644
index 0000000000..4e4114049a
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-image.ts
@@ -0,0 +1,8 @@
+import { useAsyncResource } from './use-async-resource';
+
+export function useAsyncImage(
+ loader: (() => Promise) | null,
+ deps: readonly unknown[],
+) {
+ return useAsyncResource(loader, deps);
+}
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-resource.ts b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-resource.ts
new file mode 100644
index 0000000000..17a7dbd2f2
--- /dev/null
+++ b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-async-resource.ts
@@ -0,0 +1,56 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+type AsyncResourceOptions = {
+ dispose?: (resource: T) => void;
+};
+
+export function useAsyncResource(
+ loader: (() => Promise) | null,
+ deps: readonly unknown[],
+ options?: AsyncResourceOptions,
+) {
+ const [resource, setResource] = useState(null);
+
+ useEffect(() => {
+ if (!loader) {
+ setResource(null);
+ return;
+ }
+
+ let cancelled = false;
+ let loadedResource: T | null = null;
+
+ setResource(null);
+
+ void Promise.resolve()
+ .then(() => loader())
+ .then(
+ (result) => {
+ if (cancelled) {
+ options?.dispose?.(result);
+ return;
+ }
+
+ loadedResource = result;
+ setResource(result);
+ },
+ (error) => {
+ if (!cancelled) {
+ console.error(error);
+ }
+ },
+ );
+
+ return () => {
+ cancelled = true;
+ if (loadedResource) {
+ options?.dispose?.(loadedResource);
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+
+ return resource;
+}
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/use-webgl-policy.ts b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-webgl-policy.ts
similarity index 96%
rename from packages/twenty-website-new/src/lib/visual-runtime/use-webgl-policy.ts
rename to packages/twenty-website-new/src/lib/visual-runtime/hooks/use-webgl-policy.ts
index c967a87c03..7e0971e0e8 100644
--- a/packages/twenty-website-new/src/lib/visual-runtime/use-webgl-policy.ts
+++ b/packages/twenty-website-new/src/lib/visual-runtime/hooks/use-webgl-policy.ts
@@ -5,7 +5,7 @@ import { useEffect, useState } from 'react';
import {
evaluateWebGlPolicy,
type WebGlPolicyDecision,
-} from './visual-runtime-policy';
+} from '../utils/visual-runtime-policy';
const PESSIMISTIC_INITIAL_DECISION: WebGlPolicyDecision = {
allowed: false,
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/index.ts b/packages/twenty-website-new/src/lib/visual-runtime/index.ts
index 310af48e66..7da5912d3a 100644
--- a/packages/twenty-website-new/src/lib/visual-runtime/index.ts
+++ b/packages/twenty-website-new/src/lib/visual-runtime/index.ts
@@ -1,57 +1,17 @@
-export {
- evaluateWebGlPolicy,
- detectWebGlSupport,
- detectPrefersReducedMotion,
- isHeavyVisualsKillSwitchEnabled,
- WebGlUnavailableError,
- type WebGlPolicyDecision,
- type WebGlPolicyDenialReason,
-} from './visual-runtime-policy';
+export { evaluateWebGlPolicy } from './utils/visual-runtime-policy';
-export {
- getActiveWebGlContextCount,
- getMaxActiveWebGlContexts,
- subscribeToActiveWebGlContextCount,
- tryReserveWebGlContextSlot,
-} from './active-webgl-context-budget';
-
-export { useWebGlPolicy } from './use-webgl-policy';
-export { WebGlErrorBoundary } from './webgl-error-boundary';
-export { WebGlMount } from './webgl-mount';
-export { loadVisualImage } from './load-visual-image';
-export {
- createVisualMountScheduler,
- scheduleVisualMount,
- visualMountScheduler,
- type ScheduleVisualMountOptions,
- type VisualMountPriority,
- type VisualMountScheduler,
-} from './visual-mount-scheduler';
+export { WebGlMount } from './components/WebglMount';
+export { loadVisualImage } from './utils/load-visual-image';
+export { scheduleVisualMount } from './utils/visual-mount-scheduler';
export {
createSiteWebGlRenderer,
- reportSiteWebGlRendererCreationFailure,
- SITE_WEBGL_CONTEXT_LOST_EVENT,
tryCreateSiteWebGlRenderer,
- type SiteWebGlRendererCreationFailureHandler,
- type SiteWebGlRendererParameters,
-} from './create-site-webgl-renderer';
+} from './utils/create-site-webgl-renderer';
export {
createVisualRenderLoop,
- reportVisualRenderLoopErrorInDevelopment,
- type CreateVisualRenderLoopOptions,
type VisualRenderLoop,
- type VisualRenderLoopCanceller,
- type VisualRenderLoopDocument,
- type VisualRenderLoopErrorHandler,
type VisualRenderLoopFrame,
- type VisualRenderLoopFrameRenderer,
- type VisualRenderLoopScheduler,
-} from './visual-render-loop';
+} from './utils/visual-render-loop';
-export {
- createBoundedFailureCache,
- type BoundedFailureCache,
-} from './bounded-failure-cache';
-
-export { DRACO_DECODER_PATH, DRACO_DECODER_ORIGIN } from './draco-decoder-path';
+export { createBoundedFailureCache } from './utils/bounded-failure-cache';
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/active-webgl-context-budget.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/active-webgl-context-budget.ts
similarity index 89%
rename from packages/twenty-website-new/src/lib/visual-runtime/active-webgl-context-budget.ts
rename to packages/twenty-website-new/src/lib/visual-runtime/utils/active-webgl-context-budget.ts
index 31df0a5779..370b36a176 100644
--- a/packages/twenty-website-new/src/lib/visual-runtime/active-webgl-context-budget.ts
+++ b/packages/twenty-website-new/src/lib/visual-runtime/utils/active-webgl-context-budget.ts
@@ -8,7 +8,7 @@ function readNumberEnv(value: string | undefined, fallback: number): number {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
-export function getMaxActiveWebGlContexts(): number {
+function getMaxActiveWebGlContexts(): number {
return readNumberEnv(
process.env.NEXT_PUBLIC_MAX_WEBGL_CONTEXTS,
DEFAULT_MAX_ACTIVE_WEBGL_CONTEXTS,
@@ -26,10 +26,6 @@ function notify() {
}
}
-export function getActiveWebGlContextCount(): number {
- return activeCount;
-}
-
export function tryReserveWebGlContextSlot(): (() => void) | null {
if (activeCount >= getMaxActiveWebGlContexts()) {
return null;
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/bounded-failure-cache.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/bounded-failure-cache.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/visual-runtime/bounded-failure-cache.ts
rename to packages/twenty-website-new/src/lib/visual-runtime/utils/bounded-failure-cache.ts
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/create-site-webgl-renderer.ts
similarity index 89%
rename from packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts
rename to packages/twenty-website-new/src/lib/visual-runtime/utils/create-site-webgl-renderer.ts
index 9f33a01c10..b526b0d915 100644
--- a/packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts
+++ b/packages/twenty-website-new/src/lib/visual-runtime/utils/create-site-webgl-renderer.ts
@@ -1,6 +1,6 @@
import * as THREE from 'three';
-export type SiteWebGlRendererParameters = THREE.WebGLRendererParameters & {
+type SiteWebGlRendererParameters = THREE.WebGLRendererParameters & {
onContextLost?: (event: WebGLContextEvent) => void;
};
@@ -59,9 +59,9 @@ export function createSiteWebGlRenderer(
return renderer;
}
-export type SiteWebGlRendererCreationFailureHandler = (error: unknown) => void;
+type SiteWebGlRendererCreationFailureHandler = (error: unknown) => void;
-export const reportSiteWebGlRendererCreationFailure: SiteWebGlRendererCreationFailureHandler =
+const reportSiteWebGlRendererCreationFailure: SiteWebGlRendererCreationFailureHandler =
(error) => {
if (process.env.NODE_ENV !== 'production') {
console.error('WebGL renderer creation failed:', error);
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/draco-decoder-path.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/draco-decoder-path.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/visual-runtime/draco-decoder-path.ts
rename to packages/twenty-website-new/src/lib/visual-runtime/utils/draco-decoder-path.ts
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/load-visual-image.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/load-visual-image.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/visual-runtime/load-visual-image.ts
rename to packages/twenty-website-new/src/lib/visual-runtime/utils/load-visual-image.ts
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/visual-mount-scheduler.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/visual-mount-scheduler.ts
similarity index 97%
rename from packages/twenty-website-new/src/lib/visual-runtime/visual-mount-scheduler.ts
rename to packages/twenty-website-new/src/lib/visual-runtime/utils/visual-mount-scheduler.ts
index 00a6789414..7cacff38cc 100644
--- a/packages/twenty-website-new/src/lib/visual-runtime/visual-mount-scheduler.ts
+++ b/packages/twenty-website-new/src/lib/visual-runtime/utils/visual-mount-scheduler.ts
@@ -14,12 +14,12 @@ type VisualMountSchedulerHost = {
setTimeout?: (callback: () => void, delayMs: number) => TimeoutHandle;
};
-export type ScheduleVisualMountOptions = {
+type ScheduleVisualMountOptions = {
priority?: VisualMountPriority;
timeoutMs?: number;
};
-export type VisualMountScheduler = {
+type VisualMountScheduler = {
schedule: (
callback: () => void,
options?: ScheduleVisualMountOptions,
@@ -207,6 +207,6 @@ export function createVisualMountScheduler(
};
}
-export const visualMountScheduler = createVisualMountScheduler();
+const visualMountScheduler = createVisualMountScheduler();
export const scheduleVisualMount = visualMountScheduler.schedule;
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/visual-render-loop.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/visual-render-loop.ts
similarity index 89%
rename from packages/twenty-website-new/src/lib/visual-runtime/visual-render-loop.ts
rename to packages/twenty-website-new/src/lib/visual-runtime/utils/visual-render-loop.ts
index 5ed9c61ba1..c81fcc95fb 100644
--- a/packages/twenty-website-new/src/lib/visual-runtime/visual-render-loop.ts
+++ b/packages/twenty-website-new/src/lib/visual-runtime/utils/visual-render-loop.ts
@@ -2,7 +2,7 @@ import {
observeElementVisibility,
type ObserveElementVisibilityOptions,
} from '@/lib/dom/observe-element-visibility';
-import { runCleanupTasks } from '@/lib/lifecycle/run-cleanup-tasks';
+import { runCleanupTasks } from '@/lib/react/run-cleanup-tasks';
export type VisualRenderLoop = {
dispose: () => void;
@@ -11,7 +11,7 @@ export type VisualRenderLoop = {
stop: () => void;
};
-export type VisualRenderLoopErrorHandler = (error: unknown) => void;
+type VisualRenderLoopErrorHandler = (error: unknown) => void;
export type VisualRenderLoopDocument = {
addEventListener: Document['addEventListener'];
@@ -19,22 +19,20 @@ export type VisualRenderLoopDocument = {
removeEventListener: Document['removeEventListener'];
};
-export type VisualRenderLoopScheduler = (
- callback: FrameRequestCallback,
-) => number;
+type VisualRenderLoopScheduler = (callback: FrameRequestCallback) => number;
-export type VisualRenderLoopCanceller = (handle: number) => void;
+type VisualRenderLoopCanceller = (handle: number) => void;
export type VisualRenderLoopFrame = {
deltaSeconds: number;
elapsedSeconds: number;
timestamp: DOMHighResTimeStamp;
};
-export type VisualRenderLoopFrameRenderer = (
+type VisualRenderLoopFrameRenderer = (
timestamp: DOMHighResTimeStamp,
frame: VisualRenderLoopFrame,
) => boolean | void;
-export type CreateVisualRenderLoopOptions = {
+type CreateVisualRenderLoopOptions = {
cancelAnimationFrame?: VisualRenderLoopCanceller;
document?: VisualRenderLoopDocument | null;
onFrameError?: VisualRenderLoopErrorHandler;
@@ -47,12 +45,13 @@ export type CreateVisualRenderLoopOptions = {
visibilityObserver?: typeof observeElementVisibility;
};
-export const reportVisualRenderLoopErrorInDevelopment: VisualRenderLoopErrorHandler =
- (error) => {
- if (process.env.NODE_ENV !== 'production') {
- console.error('Visual render loop failed:', error);
- }
- };
+const reportVisualRenderLoopErrorInDevelopment: VisualRenderLoopErrorHandler = (
+ error,
+) => {
+ if (process.env.NODE_ENV !== 'production') {
+ console.error('Visual render loop failed:', error);
+ }
+};
const MAX_FRAME_DELTA_SECONDS = 0.1;
diff --git a/packages/twenty-website-new/src/lib/visual-runtime/visual-runtime-policy.ts b/packages/twenty-website-new/src/lib/visual-runtime/utils/visual-runtime-policy.ts
similarity index 90%
rename from packages/twenty-website-new/src/lib/visual-runtime/visual-runtime-policy.ts
rename to packages/twenty-website-new/src/lib/visual-runtime/utils/visual-runtime-policy.ts
index 055e7ce4b7..cc5ca0c5cd 100644
--- a/packages/twenty-website-new/src/lib/visual-runtime/visual-runtime-policy.ts
+++ b/packages/twenty-website-new/src/lib/visual-runtime/utils/visual-runtime-policy.ts
@@ -2,7 +2,7 @@ export type WebGlPolicyDecision =
| { allowed: true; reducedMotion: boolean }
| { allowed: false; reason: WebGlPolicyDenialReason; reducedMotion: boolean };
-export type WebGlPolicyDenialReason = 'kill-switch' | 'no-webgl-support';
+type WebGlPolicyDenialReason = 'kill-switch' | 'no-webgl-support';
export class WebGlUnavailableError extends Error {
readonly reason: WebGlPolicyDenialReason;
@@ -22,13 +22,13 @@ function readBooleanEnv(value: string | undefined): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
-export function isHeavyVisualsKillSwitchEnabled(): boolean {
+function isHeavyVisualsKillSwitchEnabled(): boolean {
return readBooleanEnv(process.env.NEXT_PUBLIC_DISABLE_HEAVY_VISUALS);
}
let cachedSupportProbe: boolean | null = null;
-export function detectWebGlSupport(): boolean {
+function detectWebGlSupport(): boolean {
if (cachedSupportProbe !== null) {
return cachedSupportProbe;
}
@@ -63,7 +63,7 @@ export function detectWebGlSupport(): boolean {
}
}
-export function detectPrefersReducedMotion(): boolean {
+function detectPrefersReducedMotion(): boolean {
if (typeof window === 'undefined' || !('matchMedia' in window)) {
return false;
}
diff --git a/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts b/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts
index 17547491ed..be73fe438c 100644
--- a/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts
+++ b/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts
@@ -14,7 +14,7 @@ export const STATIC_WEBSITE_ROUTES = [
},
{
id: 'whyTwenty',
- path: '/resources/why-twenty',
+ path: '/why-twenty',
title: msg`Why Twenty — The Open Source CRM Built to Be Customised`,
description: msg`Packaged CRMs make every company look the same. Twenty is the open source CRM teams shape around their workflow, with a modern UI and a developer-first platform.`,
changeFrequency: 'monthly',
diff --git a/packages/twenty-website-new/src/proxy.ts b/packages/twenty-website-new/src/proxy.ts
index cadd25fa06..8d623b8432 100644
--- a/packages/twenty-website-new/src/proxy.ts
+++ b/packages/twenty-website-new/src/proxy.ts
@@ -1,8 +1,8 @@
import { NextResponse, type NextRequest } from 'next/server';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
-import { KNOWN_PUBLIC_APP_LOCALE_BY_RAW } from '@/lib/i18n/app-locale-set';
-import { LOCALE_BY_URL_SEGMENT } from '@/lib/i18n/website-locale-segments';
+import { KNOWN_PUBLIC_APP_LOCALE_BY_RAW } from '@/lib/i18n/utils/app-locale-set';
+import { LOCALE_BY_URL_SEGMENT } from '@/lib/i18n/utils/website-locale-segments';
export const proxy = (request: NextRequest) => {
const { pathname, search } = request.nextUrl;
diff --git a/packages/twenty-website-new/src/sections/Articles/index.ts b/packages/twenty-website-new/src/sections/Articles/index.ts
new file mode 100644
index 0000000000..30329eaad1
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Articles/index.ts
@@ -0,0 +1 @@
+export { Articles } from './components';
diff --git a/packages/twenty-website-new/src/sections/CaseStudy/components/CaseStudySectionNav.tsx b/packages/twenty-website-new/src/sections/CaseStudy/components/CaseStudySectionNav.tsx
index 0a3becf6fd..d3c26f07d1 100644
--- a/packages/twenty-website-new/src/sections/CaseStudy/components/CaseStudySectionNav.tsx
+++ b/packages/twenty-website-new/src/sections/CaseStudy/components/CaseStudySectionNav.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useRenderMessage } from '@/lib/i18n/use-render-message';
+import { useLingui } from '@lingui/react';
import { useScheduledOnScroll } from '@/lib/scroll';
import { theme } from '@/theme';
import type { MessageDescriptor } from '@lingui/core';
@@ -154,9 +154,9 @@ export function CaseStudySectionNav({
items,
label = DEFAULT_LABEL,
}: CaseStudySectionNavProps) {
- const renderText = useRenderMessage();
- const resolvedLabel = renderText(label);
- const resolvedItems = items.map(renderText);
+ const { i18n } = useLingui();
+ const resolvedLabel = i18n._(label);
+ const resolvedItems = items.map((item) => i18n._(item));
const [visible, setVisible] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
diff --git a/packages/twenty-website-new/src/sections/CaseStudy/components/Hero.tsx b/packages/twenty-website-new/src/sections/CaseStudy/components/Hero.tsx
index 7fd0c472c9..e9298e656e 100644
--- a/packages/twenty-website-new/src/sections/CaseStudy/components/Hero.tsx
+++ b/packages/twenty-website-new/src/sections/CaseStudy/components/Hero.tsx
@@ -1,15 +1,14 @@
import type { CaseStudyData } from '@/lib/customers';
-import { Container, Heading } from '@/design-system/components';
+import { Container } from '@/design-system/components';
import { CLIENT_ICONS } from '@/icons';
+import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
import { LocalizedLink } from '@/lib/i18n';
import { CustomerCasesCover } from '@/sections/CaseStudyCatalog/visuals/CustomerCasesCover';
import { theme } from '@/theme';
-import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { IconArrowLeft, IconClock } from '@tabler/icons-react';
import Image from 'next/image';
-
const CATALOG_LOGO_WIDTHS: Record = {
'nine-dots': 72,
'alternative-partners': 220,
@@ -230,15 +229,10 @@ type HeroProps = {
hero: CaseStudyData['hero'];
dashColor?: string;
hoverDashColor?: string;
- renderText: (descriptor: MessageDescriptor) => string;
};
-export function Hero({
- hero,
- dashColor,
- hoverDashColor,
- renderText,
-}: HeroProps) {
+export function Hero({ hero, dashColor, hoverDashColor }: HeroProps) {
+ const i18n = getServerI18n();
const ClientIcon = CLIENT_ICONS[hero.clientIcon];
const authorInitials = hero.author
.split(' ')
@@ -262,15 +256,7 @@ export function Hero({
-
-
-
+ {hero.title}
{hero.authorAvatarSrc ? (
@@ -289,13 +275,13 @@ export function Hero({
- {renderText(msg`Back`)}
+ {i18n._(msg`Back`)}
{hero.author}
{hero.authorRole ? (
- {renderText(hero.authorRole)}
+ {i18n._(hero.authorRole)}
) : null}
diff --git a/packages/twenty-website-new/src/sections/CaseStudy/components/Highlights.tsx b/packages/twenty-website-new/src/sections/CaseStudy/components/Highlights.tsx
index 56753666ae..1ffa43106e 100644
--- a/packages/twenty-website-new/src/sections/CaseStudy/components/Highlights.tsx
+++ b/packages/twenty-website-new/src/sections/CaseStudy/components/Highlights.tsx
@@ -1,6 +1,7 @@
import { Container } from '@/design-system/components';
-import type { MessageDescriptor } from '@lingui/core';
+import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
import { theme } from '@/theme';
+import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { styled } from '@linaria/react';
@@ -115,10 +116,10 @@ const Label = styled.span`
type HighlightsProps = {
industry?: MessageDescriptor;
kpis?: { value: MessageDescriptor; label: MessageDescriptor }[];
- renderText: (descriptor: MessageDescriptor) => string;
};
-export function Highlights({ industry, kpis, renderText }: HighlightsProps) {
+export function Highlights({ industry, kpis }: HighlightsProps) {
+ const i18n = getServerI18n();
const cells: { value: MessageDescriptor; label: MessageDescriptor }[] = [];
if (industry) {
cells.push({ value: industry, label: msg`Industry` });
@@ -140,8 +141,8 @@ export function Highlights({ industry, kpis, renderText }: HighlightsProps) {
{cells.map((cell, index) => (
- {renderText(cell.value)}
- {renderText(cell.label)}
+ {i18n._(cell.value)}
+ {i18n._(cell.label)}
|
))}
diff --git a/packages/twenty-website-new/src/sections/CaseStudy/components/TextBlock.tsx b/packages/twenty-website-new/src/sections/CaseStudy/components/TextBlock.tsx
index e3663f013f..1565d9abd6 100644
--- a/packages/twenty-website-new/src/sections/CaseStudy/components/TextBlock.tsx
+++ b/packages/twenty-website-new/src/sections/CaseStudy/components/TextBlock.tsx
@@ -3,12 +3,11 @@ import {
Body as BaseBody,
Container,
Eyebrow,
- Heading,
+ HeadingPart,
} from '@/design-system/components';
+import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
import { theme } from '@/theme';
-import type { MessageDescriptor } from '@lingui/core';
import { styled } from '@linaria/react';
-
const Section = styled.section`
background-color: ${theme.colors.primary.background[100]};
color: ${theme.colors.primary.text[100]};
@@ -125,47 +124,40 @@ function parseCallout(raw: string): {
type TextBlockProps = {
block: CaseStudyTextBlock;
isLast?: boolean;
- renderText: (descriptor: MessageDescriptor) => string;
sectionId?: string;
};
export function TextBlock({
block,
isLast = false,
- renderText,
sectionId,
}: TextBlockProps) {
+ const i18n = getServerI18n();
return (
{block.eyebrow && (
-
+
+
+ {i18n._(block.eyebrow)}
+
+
)}
-
+ {block.heading}
{block.paragraphs.map((paragraph, index) => (
+ >
+ {i18n._(paragraph)}
+
))}
{block.callout &&
(() => {
diff --git a/packages/twenty-website-new/src/sections/CaseStudy/index.ts b/packages/twenty-website-new/src/sections/CaseStudy/index.ts
new file mode 100644
index 0000000000..d4fadf29a2
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/CaseStudy/index.ts
@@ -0,0 +1 @@
+export { CaseStudy } from './components';
diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Card.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Card.tsx
index df2432707a..8ba0bb5cff 100644
--- a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Card.tsx
+++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Card.tsx
@@ -1,13 +1,14 @@
import type { CaseStudyCatalogEntry } from '@/lib/customers';
-import { ArrowRightIcon, CLIENT_ICONS } from '@/icons';
+import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
import { LocalizedLink } from '@/lib/i18n';
-import { CustomerCasesCover } from '@/sections/CaseStudyCatalog/visuals/CustomerCasesCover';
import { theme } from '@/theme';
-import type { MessageDescriptor } from '@lingui/core';
import { styled } from '@linaria/react';
-import Image from 'next/image';
import type { CSSProperties } from 'react';
+import { CardFooter } from './CardFooter';
+import { CardKpisDefault, CardKpisLarge } from './CardKpis';
+import { CardThumbnail } from './CardThumbnail';
+
type CardVariant = 'default' | 'large';
type CaseStudyCardStyle = CSSProperties & {
@@ -63,56 +64,6 @@ const CardLink = styled(LocalizedLink)`
}
`;
-const Thumbnail = styled.div<{ variant: 'default' | 'large' }>`
- align-items: center;
- background-color: #000;
- display: flex;
- flex-shrink: 0;
- height: 200px;
- justify-content: center;
- overflow: hidden;
- position: relative;
- width: 100%;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- height: ${({ variant }) => (variant === 'large' ? 'auto' : '240px')};
- min-height: ${({ variant }) => (variant === 'large' ? '460px' : '0')};
- width: ${({ variant }) => (variant === 'large' ? '50%' : '100%')};
- }
-`;
-
-const CoverLayer = styled.div`
- inset: 0;
- position: absolute;
-`;
-
-const LogoLayer = styled.div`
- align-items: center;
- display: flex;
- inset: 0;
- justify-content: center;
- pointer-events: none;
- position: absolute;
-`;
-
-const ThumbnailBadge = styled.span`
- bottom: ${theme.spacing(4)};
- color: ${theme.colors.secondary.text[60]};
- font-family: ${theme.font.family.mono};
- font-size: ${theme.font.size(3)};
- font-weight: ${theme.font.weight.medium};
- left: ${theme.spacing(6)};
- letter-spacing: 0.08em;
- position: absolute;
- text-transform: uppercase;
- z-index: 2;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- bottom: ${theme.spacing(6)};
- left: ${theme.spacing(8)};
- }
-`;
-
const ContentWrapper = styled.div<{ variant: 'default' | 'large' }>`
display: flex;
flex: 1;
@@ -168,10 +119,6 @@ const Title = styled.h3<{ variant: 'default' | 'large' }>`
}
`;
-const TitleSans = styled.span`
- font-family: ${theme.font.family.sans};
-`;
-
const Quote = styled.blockquote`
border-left: 2px solid ${theme.colors.highlight[100]};
color: ${theme.colors.primary.text[60]};
@@ -202,224 +149,6 @@ const Summary = styled.p`
overflow: hidden;
`;
-const KpiRow = styled.div<{ count: number }>`
- border-top: 1px solid ${theme.colors.primary.border[10]};
- display: grid;
- grid-template-columns: repeat(
- ${({ count }) => (count > 2 ? 2 : count)},
- minmax(0, 1fr)
- );
- margin-top: ${theme.spacing(2)};
-
- @media (min-width: ${theme.breakpoints.md}px) {
- grid-template-columns: ${({ count }) => `repeat(${count}, minmax(0, 1fr))`};
- margin-top: auto;
- }
-`;
-
-const KpiCell = styled.div<{ index: number; count: number }>`
- border-left: 1px solid ${theme.colors.primary.border[10]};
- border-top: ${({ index, count }) =>
- count > 2 && index >= 2
- ? `1px solid ${theme.colors.primary.border[10]}`
- : 'none'};
- display: flex;
- flex-direction: column;
- gap: ${theme.spacing(1.5)};
- padding: ${theme.spacing(4)} ${theme.spacing(3)};
-
- &:nth-child(odd) {
- border-left: none;
- padding-left: 0;
- }
-
- @media (min-width: ${theme.breakpoints.md}px) {
- border-left: 1px solid ${theme.colors.primary.border[10]};
- border-top: none;
- padding: ${theme.spacing(5)} ${theme.spacing(4)};
-
- &:first-child {
- border-left: none;
- padding-left: 0;
- }
-
- &:nth-child(odd) {
- border-left: 1px solid ${theme.colors.primary.border[10]};
- padding-left: ${theme.spacing(4)};
- }
-
- &:first-child {
- border-left: none;
- padding-left: 0;
- }
- }
-`;
-
-const KpiValue = styled.span`
- color: ${theme.colors.primary.text[100]};
- font-family: ${theme.font.family.serif};
- font-size: ${theme.font.size(6)};
- font-weight: ${theme.font.weight.light};
- line-height: ${theme.lineHeight(7)};
-
- @media (min-width: ${theme.breakpoints.md}px) {
- font-size: ${theme.font.size(7)};
- line-height: ${theme.lineHeight(8)};
- }
-`;
-
-const KpiLabel = styled.span`
- color: ${theme.colors.primary.text[40]};
- font-family: ${theme.font.family.mono};
- font-size: ${theme.font.size(3)};
- font-weight: ${theme.font.weight.medium};
- letter-spacing: 0.08em;
- line-height: ${theme.lineHeight(4)};
- text-transform: uppercase;
-`;
-
-const KpiChipRow = styled.div`
- display: flex;
- flex-wrap: wrap;
- gap: ${theme.spacing(2)};
- margin-top: ${theme.spacing(1)};
- padding-bottom: ${theme.spacing(4)};
- padding-left: ${theme.spacing(6)};
- padding-right: ${theme.spacing(6)};
-
- @media (min-width: ${theme.breakpoints.md}px) {
- padding-bottom: ${theme.spacing(5)};
- }
-`;
-
-const KpiChip = styled.span`
- align-items: center;
- background-color: ${theme.colors.primary.text[5]};
- border: 1px solid ${theme.colors.primary.border[10]};
- border-radius: 999px;
- color: ${theme.colors.primary.text[60]};
- display: inline-flex;
- font-family: ${theme.font.family.sans};
- font-size: ${theme.font.size(3)};
- font-weight: ${theme.font.weight.regular};
- gap: ${theme.spacing(2.5)};
- line-height: ${theme.lineHeight(4)};
- padding: ${theme.spacing(1.5)} ${theme.spacing(3.5)};
- white-space: nowrap;
-`;
-
-const KpiChipValue = styled.span`
- color: ${theme.colors.primary.text[100]};
- font-family: ${theme.font.family.sans};
- font-size: ${theme.font.size(3.5)};
- font-weight: ${theme.font.weight.medium};
- letter-spacing: -0.01em;
-`;
-
-const KpiChipDivider = styled.span`
- background-color: ${theme.colors.primary.border[20]};
- flex-shrink: 0;
- height: 14px;
- width: 1px;
-`;
-
-const KpiChipLabel = styled.span`
- color: ${theme.colors.primary.text[60]};
- font-family: ${theme.font.family.mono};
- font-size: ${theme.font.size(2.5)};
- font-weight: ${theme.font.weight.medium};
- letter-spacing: 0.08em;
- text-transform: uppercase;
-`;
-
-const Footer = styled.div<{ variant: 'default' | 'large' }>`
- align-items: center;
- border-top: 1px solid ${theme.colors.primary.border[10]};
- display: flex;
- gap: ${theme.spacing(3)};
- justify-content: space-between;
- margin-left: ${theme.spacing(6)};
- margin-right: ${theme.spacing(6)};
- padding-bottom: ${theme.spacing(5)};
- padding-top: ${theme.spacing(5)};
-
- @media (min-width: ${theme.breakpoints.md}px) {
- margin-left: ${({ variant }) =>
- variant === 'large' ? '0' : theme.spacing(6)};
- margin-right: ${({ variant }) =>
- variant === 'large' ? '0' : theme.spacing(6)};
- padding-bottom: ${({ variant }) =>
- variant === 'large' ? '0' : theme.spacing(5)};
- padding-top: ${({ variant }) =>
- variant === 'large' ? theme.spacing(6) : theme.spacing(5)};
- }
-`;
-
-const AuthorGroup = styled.div`
- align-items: center;
- display: flex;
- flex: 1;
- gap: ${theme.spacing(3)};
- min-width: 0;
-`;
-
-const AuthorAvatar = styled.div`
- align-items: center;
- background-color: ${theme.colors.highlight[100]};
- border-radius: 50%;
- color: ${theme.colors.secondary.text[100]};
- display: flex;
- flex-shrink: 0;
- font-family: ${theme.font.family.sans};
- font-size: ${theme.font.size(3)};
- font-weight: ${theme.font.weight.medium};
- height: 32px;
- justify-content: center;
- width: 32px;
-`;
-
-const AuthorAvatarPhoto = styled.div`
- border-radius: 50%;
- flex-shrink: 0;
- height: 32px;
- overflow: hidden;
- position: relative;
- width: 32px;
-`;
-
-const AuthorText = styled.div`
- color: ${theme.colors.primary.text[80]};
- font-family: ${theme.font.family.sans};
- font-size: ${theme.font.size(3.5)};
- font-weight: ${theme.font.weight.regular};
- line-height: ${theme.lineHeight(4)};
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-`;
-
-const AuthorRoleText = styled.span`
- color: ${theme.colors.primary.text[40]};
-`;
-
-const ReadIconButton = styled.span`
- align-items: center;
- border: 1px solid ${theme.colors.primary.border[20]};
- border-radius: ${theme.radius(2)};
- color: ${theme.colors.primary.text[80]};
- display: inline-flex;
- flex-shrink: 0;
- height: 40px;
- justify-content: center;
- transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
- width: 40px;
-
- ${`a:hover &`} {
- transform: scale(1.08);
- }
-`;
-
const IndustryLabel = styled.span`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
@@ -430,47 +159,22 @@ const IndustryLabel = styled.span`
text-transform: uppercase;
`;
-const CATALOG_LOGO_WIDTHS: Record<
- CaseStudyCatalogEntry['hero']['clientIcon'],
- number
-> = {
- 'nine-dots': 72,
- 'alternative-partners': 220,
- netzero: 180,
- 'act-education': 110,
- w3villa: 150,
- 'elevate-consulting': 160,
-};
-
-const LARGE_LOGO_SCALE = 1.4;
-
type CardProps = {
- entry: CaseStudyCatalogEntry;
- index?: number;
- renderText: (descriptor: MessageDescriptor) => string;
- variant?: CardVariant;
dashColor?: string;
+ entry: CaseStudyCatalogEntry;
hoverDashColor?: string;
+ index?: number;
+ variant?: CardVariant;
};
export function Card({
- entry,
- index = 0,
- renderText,
- variant = 'default',
dashColor,
+ entry,
hoverDashColor,
+ index = 0,
+ variant = 'default',
}: CardProps) {
- const ClientIcon = CLIENT_ICONS[entry.hero.clientIcon];
- const baseLogoWidth = CATALOG_LOGO_WIDTHS[entry.hero.clientIcon] ?? 140;
- const logoWidth =
- variant === 'large' ? baseLogoWidth * LARGE_LOGO_SCALE : baseLogoWidth;
- const initials = entry.hero.author
- .split(' ')
- .map((word) => word[0])
- .join('');
-
- const coverImageSrc = entry.catalogCard.coverImageSrc;
+ const i18n = getServerI18n();
const isLarge = variant === 'large';
const hasQuote = isLarge && entry.quote;
const kpiCount = entry.kpis.length;
@@ -480,104 +184,40 @@ export function Card({
return (
-
- {coverImageSrc ? (
-
-
-
- ) : null}
- {ClientIcon ? (
-
-
-
- ) : null}
- {isLarge ? (
- Case · {entry.hero.readingTime}
- ) : null}
-
+
- {renderText(entry.industry)}
-
- {entry.hero.title.map((segment, segmentIndex) =>
- segment.fontFamily === 'sans' ? (
-
- {renderText(segment.text)}
-
- ) : (
- {renderText(segment.text)}
- ),
- )}
-
+ {i18n._(entry.industry)}
+ {i18n._(entry.hero.title)}
{hasQuote && entry.quote ? (
- “{renderText(entry.quote.text)}”
+ “{i18n._(entry.quote.text)}”
) : !isLarge ? (
- {renderText(entry.catalogCard.summary)}
+ {i18n._(entry.catalogCard.summary)}
) : null}
{isLarge && kpiCount > 0 ? (
-
- {entry.kpis.map((kpi, kpiIndex) => (
-
- {renderText(kpi.value)}
- {renderText(kpi.label)}
-
- ))}
-
+
) : null}
{!isLarge && kpiCount > 0 ? (
-
- {entry.kpis.map((kpi, kpiIndex) => (
-
- {renderText(kpi.value)}
-
- {renderText(kpi.label)}
-
- ))}
-
+
) : null}
-
-
- {entry.hero.authorAvatarSrc ? (
-
-
-
- ) : (
- {initials}
- )}
- {hasQuote && entry.quote ? (
-
- {entry.quote.author}{' '}
-
- · {renderText(entry.quote.role)}
-
-
- ) : (
-
- {entry.hero.author}{' '}
-
- · {renderText(entry.authorRole)}
-
-
- )}
-
-
-
-
-
+
);
diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardFooter.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardFooter.tsx
new file mode 100644
index 0000000000..8727f7e1e5
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardFooter.tsx
@@ -0,0 +1,154 @@
+import { ArrowRightIcon } from '@/icons';
+import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
+import { theme } from '@/theme';
+import type { MessageDescriptor } from '@lingui/core';
+import { styled } from '@linaria/react';
+import Image from 'next/image';
+
+const StyledFooter = styled.div<{ variant: 'default' | 'large' }>`
+ align-items: center;
+ border-top: 1px solid ${theme.colors.primary.border[10]};
+ display: flex;
+ gap: ${theme.spacing(3)};
+ justify-content: space-between;
+ margin-left: ${theme.spacing(6)};
+ margin-right: ${theme.spacing(6)};
+ padding-bottom: ${theme.spacing(5)};
+ padding-top: ${theme.spacing(5)};
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ margin-left: ${({ variant }) =>
+ variant === 'large' ? '0' : theme.spacing(6)};
+ margin-right: ${({ variant }) =>
+ variant === 'large' ? '0' : theme.spacing(6)};
+ padding-bottom: ${({ variant }) =>
+ variant === 'large' ? '0' : theme.spacing(5)};
+ padding-top: ${({ variant }) =>
+ variant === 'large' ? theme.spacing(6) : theme.spacing(5)};
+ }
+`;
+
+const AuthorGroup = styled.div`
+ align-items: center;
+ display: flex;
+ flex: 1;
+ gap: ${theme.spacing(3)};
+ min-width: 0;
+`;
+
+const AuthorAvatar = styled.div`
+ align-items: center;
+ background-color: ${theme.colors.highlight[100]};
+ border-radius: 50%;
+ color: ${theme.colors.secondary.text[100]};
+ display: flex;
+ flex-shrink: 0;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3)};
+ font-weight: ${theme.font.weight.medium};
+ height: 32px;
+ justify-content: center;
+ width: 32px;
+`;
+
+const AuthorAvatarPhoto = styled.div`
+ border-radius: 50%;
+ flex-shrink: 0;
+ height: 32px;
+ overflow: hidden;
+ position: relative;
+ width: 32px;
+`;
+
+const AuthorText = styled.div`
+ color: ${theme.colors.primary.text[80]};
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3.5)};
+ font-weight: ${theme.font.weight.regular};
+ line-height: ${theme.lineHeight(4)};
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const AuthorRoleText = styled.span`
+ color: ${theme.colors.primary.text[40]};
+`;
+
+const ReadIconButton = styled.span`
+ align-items: center;
+ border: 1px solid ${theme.colors.primary.border[20]};
+ border-radius: ${theme.radius(2)};
+ color: ${theme.colors.primary.text[80]};
+ display: inline-flex;
+ flex-shrink: 0;
+ height: 40px;
+ justify-content: center;
+ transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
+ width: 40px;
+
+ ${`a:hover &`} {
+ transform: scale(1.08);
+ }
+`;
+
+type CardFooterProps = {
+ author: string;
+ authorAvatarSrc?: string;
+ authorRole: MessageDescriptor;
+ quote?: {
+ author: string;
+ role: MessageDescriptor;
+ text: MessageDescriptor;
+ };
+ variant: 'default' | 'large';
+};
+
+export function CardFooter({
+ author,
+ authorAvatarSrc,
+ authorRole,
+ quote,
+ variant,
+}: CardFooterProps) {
+ const i18n = getServerI18n();
+ const initials = author
+ .split(' ')
+ .map((word) => word[0])
+ .join('');
+ const showQuoteAuthor = variant === 'large' && quote;
+
+ return (
+
+
+ {authorAvatarSrc ? (
+
+
+
+ ) : (
+ {initials}
+ )}
+ {showQuoteAuthor ? (
+
+ {quote.author}{' '}
+ · {i18n._(quote.role)}
+
+ ) : (
+
+ {author} · {i18n._(authorRole)}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardKpis.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardKpis.tsx
new file mode 100644
index 0000000000..6608a9609a
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardKpis.tsx
@@ -0,0 +1,180 @@
+import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
+import { theme } from '@/theme';
+import type { MessageDescriptor } from '@lingui/core';
+import { styled } from '@linaria/react';
+
+type Kpi = {
+ label: MessageDescriptor;
+ value: MessageDescriptor;
+};
+
+const KpiRow = styled.div<{ count: number }>`
+ border-top: 1px solid ${theme.colors.primary.border[10]};
+ display: grid;
+ grid-template-columns: repeat(
+ ${({ count }) => (count > 2 ? 2 : count)},
+ minmax(0, 1fr)
+ );
+ margin-top: ${theme.spacing(2)};
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ grid-template-columns: ${({ count }) => `repeat(${count}, minmax(0, 1fr))`};
+ margin-top: auto;
+ }
+`;
+
+const KpiCell = styled.div<{ index: number; count: number }>`
+ border-left: 1px solid ${theme.colors.primary.border[10]};
+ border-top: ${({ index, count }) =>
+ count > 2 && index >= 2
+ ? `1px solid ${theme.colors.primary.border[10]}`
+ : 'none'};
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(1.5)};
+ padding: ${theme.spacing(4)} ${theme.spacing(3)};
+
+ &:nth-child(odd) {
+ border-left: none;
+ padding-left: 0;
+ }
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ border-left: 1px solid ${theme.colors.primary.border[10]};
+ border-top: none;
+ padding: ${theme.spacing(5)} ${theme.spacing(4)};
+
+ &:first-child {
+ border-left: none;
+ padding-left: 0;
+ }
+
+ &:nth-child(odd) {
+ border-left: 1px solid ${theme.colors.primary.border[10]};
+ padding-left: ${theme.spacing(4)};
+ }
+
+ &:first-child {
+ border-left: none;
+ padding-left: 0;
+ }
+ }
+`;
+
+const KpiValue = styled.span`
+ color: ${theme.colors.primary.text[100]};
+ font-family: ${theme.font.family.serif};
+ font-size: ${theme.font.size(6)};
+ font-weight: ${theme.font.weight.light};
+ line-height: ${theme.lineHeight(7)};
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ font-size: ${theme.font.size(7)};
+ line-height: ${theme.lineHeight(8)};
+ }
+`;
+
+const KpiLabel = styled.span`
+ color: ${theme.colors.primary.text[40]};
+ font-family: ${theme.font.family.mono};
+ font-size: ${theme.font.size(3)};
+ font-weight: ${theme.font.weight.medium};
+ letter-spacing: 0.08em;
+ line-height: ${theme.lineHeight(4)};
+ text-transform: uppercase;
+`;
+
+const KpiChipRow = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${theme.spacing(2)};
+ margin-top: ${theme.spacing(1)};
+ padding-bottom: ${theme.spacing(4)};
+ padding-left: ${theme.spacing(6)};
+ padding-right: ${theme.spacing(6)};
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ padding-bottom: ${theme.spacing(5)};
+ }
+`;
+
+const KpiChip = styled.span`
+ align-items: center;
+ background-color: ${theme.colors.primary.text[5]};
+ border: 1px solid ${theme.colors.primary.border[10]};
+ border-radius: 999px;
+ color: ${theme.colors.primary.text[60]};
+ display: inline-flex;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3)};
+ font-weight: ${theme.font.weight.regular};
+ gap: ${theme.spacing(2.5)};
+ line-height: ${theme.lineHeight(4)};
+ padding: ${theme.spacing(1.5)} ${theme.spacing(3.5)};
+ white-space: nowrap;
+`;
+
+const KpiChipValue = styled.span`
+ color: ${theme.colors.primary.text[100]};
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3.5)};
+ font-weight: ${theme.font.weight.medium};
+ letter-spacing: -0.01em;
+`;
+
+const KpiChipDivider = styled.span`
+ background-color: ${theme.colors.primary.border[20]};
+ flex-shrink: 0;
+ height: 14px;
+ width: 1px;
+`;
+
+const KpiChipLabel = styled.span`
+ color: ${theme.colors.primary.text[60]};
+ font-family: ${theme.font.family.mono};
+ font-size: ${theme.font.size(2.5)};
+ font-weight: ${theme.font.weight.medium};
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+`;
+
+type CardKpisLargeProps = {
+ kpis: Kpi[];
+ variant: 'large';
+};
+
+type CardKpisDefaultProps = {
+ kpis: Kpi[];
+ variant: 'default';
+};
+
+export function CardKpisLarge({ kpis }: CardKpisLargeProps) {
+ const i18n = getServerI18n();
+
+ return (
+
+ {kpis.map((kpi, kpiIndex) => (
+
+ {i18n._(kpi.value)}
+ {i18n._(kpi.label)}
+
+ ))}
+
+ );
+}
+
+export function CardKpisDefault({ kpis }: CardKpisDefaultProps) {
+ const i18n = getServerI18n();
+
+ return (
+
+ {kpis.map((kpi, kpiIndex) => (
+
+ {i18n._(kpi.value)}
+
+ {i18n._(kpi.label)}
+
+ ))}
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardThumbnail.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardThumbnail.tsx
new file mode 100644
index 0000000000..4f2fa85f2e
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/CardThumbnail.tsx
@@ -0,0 +1,112 @@
+import { CLIENT_ICONS } from '@/icons';
+import type { CaseStudyCatalogEntry } from '@/lib/customers';
+import { CustomerCasesCover } from '@/sections/CaseStudyCatalog/visuals/CustomerCasesCover';
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+
+const StyledThumbnail = styled.div<{ variant: 'default' | 'large' }>`
+ align-items: center;
+ background-color: #000;
+ display: flex;
+ flex-shrink: 0;
+ height: 200px;
+ justify-content: center;
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ height: ${({ variant }) => (variant === 'large' ? 'auto' : '240px')};
+ min-height: ${({ variant }) => (variant === 'large' ? '460px' : '0')};
+ width: ${({ variant }) => (variant === 'large' ? '50%' : '100%')};
+ }
+`;
+
+const CoverLayer = styled.div`
+ inset: 0;
+ position: absolute;
+`;
+
+const LogoLayer = styled.div`
+ align-items: center;
+ display: flex;
+ inset: 0;
+ justify-content: center;
+ pointer-events: none;
+ position: absolute;
+`;
+
+const Badge = styled.span`
+ bottom: ${theme.spacing(4)};
+ color: ${theme.colors.secondary.text[60]};
+ font-family: ${theme.font.family.mono};
+ font-size: ${theme.font.size(3)};
+ font-weight: ${theme.font.weight.medium};
+ left: ${theme.spacing(6)};
+ letter-spacing: 0.08em;
+ position: absolute;
+ text-transform: uppercase;
+ z-index: 2;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ bottom: ${theme.spacing(6)};
+ left: ${theme.spacing(8)};
+ }
+`;
+
+const CATALOG_LOGO_WIDTHS: Record<
+ CaseStudyCatalogEntry['hero']['clientIcon'],
+ number
+> = {
+ 'nine-dots': 72,
+ 'alternative-partners': 220,
+ netzero: 180,
+ 'act-education': 110,
+ w3villa: 150,
+ 'elevate-consulting': 160,
+};
+
+const LARGE_LOGO_SCALE = 1.4;
+
+type CardThumbnailProps = {
+ clientIcon: CaseStudyCatalogEntry['hero']['clientIcon'];
+ coverImageSrc?: string;
+ dashColor?: string;
+ hoverDashColor?: string;
+ readingTime: string;
+ variant: 'default' | 'large';
+};
+
+export function CardThumbnail({
+ clientIcon,
+ coverImageSrc,
+ dashColor,
+ hoverDashColor,
+ readingTime,
+ variant,
+}: CardThumbnailProps) {
+ const ClientIcon = CLIENT_ICONS[clientIcon];
+ const baseLogoWidth = CATALOG_LOGO_WIDTHS[clientIcon] ?? 140;
+ const logoWidth =
+ variant === 'large' ? baseLogoWidth * LARGE_LOGO_SCALE : baseLogoWidth;
+
+ return (
+
+ {coverImageSrc ? (
+
+
+
+ ) : null}
+ {ClientIcon ? (
+
+
+
+ ) : null}
+ {variant === 'large' ? Case · {readingTime} : null}
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Grid.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Grid.tsx
index 36e6c51ee7..395f18eed0 100644
--- a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Grid.tsx
+++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Grid.tsx
@@ -6,7 +6,6 @@ import { Container } from '@/design-system/components';
import { PlusIcon } from '@/icons';
import { Card } from '@/sections/CaseStudyCatalog/components/Card';
import { theme } from '@/theme';
-import type { MessageDescriptor } from '@lingui/core';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
@@ -141,7 +140,6 @@ type GridProps = {
compactTop?: boolean;
entries: readonly CaseStudyCatalogEntry[];
intro?: ReactNode;
- renderText: (descriptor: MessageDescriptor) => string;
};
export function Grid({
@@ -149,7 +147,6 @@ export function Grid({
compactTop = false,
entries,
intro,
- renderText,
}: GridProps) {
const lastIndex = entries.length - 1;
@@ -203,7 +200,6 @@ export function Grid({
hoverDashColor={palette.hoverDashColor}
index={index}
key={entry.href}
- renderText={renderText}
variant={isLarge ? 'large' : 'default'}
/>
);
diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Promo.tsx b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Promo.tsx
index 24f059b79a..480c78b81c 100644
--- a/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Promo.tsx
+++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/components/Promo.tsx
@@ -1,8 +1,15 @@
import { msg } from '@lingui/core/macro';
import type { CaseStudyCatalogEntry } from '@/lib/customers';
-import { Body, Container, Eyebrow, Heading } from '@/design-system/components';
+import {
+ Body,
+ Container,
+ Eyebrow,
+ Heading,
+ HeadingPart,
+} from '@/design-system/components';
import { PlusIcon, UsersIcon } from '@/icons';
-import { LocalizedLinkButton } from '@/lib/i18n/LocalizedLinkButton';
+import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
+import { LocalizedLinkButton } from '@/lib/i18n/components/LocalizedLinkButton';
import { PromoMic } from '@/sections/CaseStudyCatalog/visuals/PromoMic';
import { theme } from '@/theme';
import type { MessageDescriptor } from '@lingui/core';
@@ -15,19 +22,7 @@ const LINE_INSET = '20px';
const TRUSTED_BY_BOTTOM_PADDING = 12;
const TRUSTED_BY_BOTTOM_PADDING_DESKTOP = 16;
-const EYEBROW = {
- text: msg`Customer Stories`,
- fontFamily: 'mono' as const,
-};
-
-const HEADING = [
- { text: msg`How teams`, fontFamily: 'serif' as const },
- { text: msg`built with Twenty`, fontFamily: 'sans' as const, newLine: true },
-];
-
-const BODY = {
- text: msg`Meet the teams who shaped Twenty into their own CRM with self-hosted deployments, AI-assisted workflows, and API-first product stacks.`,
-};
+const BODY = msg`Meet the teams who shaped Twenty into their own CRM with self-hosted deployments, AI-assisted workflows, and API-first product stacks.`;
const Section = styled.section`
background-color: ${theme.colors.primary.background[100]};
@@ -258,7 +253,6 @@ type PromoProps = {
ctaHref?: string;
ctaLabel?: MessageDescriptor;
compactTop?: boolean;
- renderText: (descriptor: MessageDescriptor) => string;
};
export function Promo({
@@ -266,8 +260,8 @@ export function Promo({
ctaHref = '/customers',
ctaLabel = msg`Explore customer stories`,
compactTop = false,
- renderText,
}: PromoProps) {
+ const i18n = getServerI18n();
return (
@@ -331,25 +325,26 @@ export function Promo({
-
-
-
+
+
+ {i18n._(msg`Customer Stories`)}
+
+
+
+
+ {i18n._(msg`How teams`)}
+
+
+
+ {i18n._(msg`built with Twenty`)}
+
+
+ {i18n._(BODY)}
diff --git a/packages/twenty-website-new/src/sections/CaseStudyCatalog/index.ts b/packages/twenty-website-new/src/sections/CaseStudyCatalog/index.ts
new file mode 100644
index 0000000000..f2bc98a323
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/CaseStudyCatalog/index.ts
@@ -0,0 +1 @@
+export { CaseStudyCatalog } from './components';
diff --git a/packages/twenty-website-new/src/lib/contact-cal/CalFormEmbed.tsx b/packages/twenty-website-new/src/sections/ContactCal/CalFormEmbed.tsx
similarity index 78%
rename from packages/twenty-website-new/src/lib/contact-cal/CalFormEmbed.tsx
rename to packages/twenty-website-new/src/sections/ContactCal/CalFormEmbed.tsx
index 618e6af572..5a68ab5831 100644
--- a/packages/twenty-website-new/src/lib/contact-cal/CalFormEmbed.tsx
+++ b/packages/twenty-website-new/src/sections/ContactCal/CalFormEmbed.tsx
@@ -1,6 +1,6 @@
'use client';
-import { CONTACT_CAL_FORM_CAL_LINK } from '@/lib/contact-cal/config';
+import { CONTACT_CAL_FORM_CAL_LINK } from '@/sections/ContactCal/config';
import Cal from '@calcom/embed-react';
export function CalFormEmbed() {
diff --git a/packages/twenty-website-new/src/lib/contact-cal/ContactCalModal.tsx b/packages/twenty-website-new/src/sections/ContactCal/ContactCalModal.tsx
similarity index 100%
rename from packages/twenty-website-new/src/lib/contact-cal/ContactCalModal.tsx
rename to packages/twenty-website-new/src/sections/ContactCal/ContactCalModal.tsx
diff --git a/packages/twenty-website-new/src/lib/contact-cal/ContactCalModalRoot.tsx b/packages/twenty-website-new/src/sections/ContactCal/ContactCalModalRoot.tsx
similarity index 57%
rename from packages/twenty-website-new/src/lib/contact-cal/ContactCalModalRoot.tsx
rename to packages/twenty-website-new/src/sections/ContactCal/ContactCalModalRoot.tsx
index 6d663968cd..44a4b150e4 100644
--- a/packages/twenty-website-new/src/lib/contact-cal/ContactCalModalRoot.tsx
+++ b/packages/twenty-website-new/src/sections/ContactCal/ContactCalModalRoot.tsx
@@ -1,13 +1,6 @@
'use client';
-import {
- createContext,
- useCallback,
- useContext,
- useMemo,
- useState,
- type ReactNode,
-} from 'react';
+import { createContext, useContext, useState, type ReactNode } from 'react';
import { ContactCalModal } from './ContactCalModal';
@@ -31,23 +24,12 @@ export function useContactCalModal() {
export function ContactCalModalRoot({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
- const openContactCalModal = useCallback(() => {
- setOpen(true);
- }, []);
-
- const closeContactCalModal = useCallback(() => {
- setOpen(false);
- }, []);
-
- const contextValue = useMemo(
- () => ({ openContactCalModal }),
- [openContactCalModal],
- );
-
return (
-
+ setOpen(true) }}
+ >
{children}
-
+ setOpen(false)} open={open} />
);
}
diff --git a/packages/twenty-website-new/src/lib/contact-cal/TalkToUsButton.tsx b/packages/twenty-website-new/src/sections/ContactCal/TalkToUsButton.tsx
similarity index 80%
rename from packages/twenty-website-new/src/lib/contact-cal/TalkToUsButton.tsx
rename to packages/twenty-website-new/src/sections/ContactCal/TalkToUsButton.tsx
index 46e26096c3..ba65986807 100644
--- a/packages/twenty-website-new/src/lib/contact-cal/TalkToUsButton.tsx
+++ b/packages/twenty-website-new/src/sections/ContactCal/TalkToUsButton.tsx
@@ -4,7 +4,7 @@ import {
BaseButton,
buttonBaseStyles,
} from '@/design-system/components/Button/BaseButton';
-import { useRenderMessage } from '@/lib/i18n/use-render-message';
+import { useLingui } from '@lingui/react';
import type { MessageDescriptor } from '@lingui/core';
import { styled } from '@linaria/react';
@@ -21,7 +21,7 @@ type TalkToUsButtonProps = {
};
export function TalkToUsButton({ color, label, variant }: TalkToUsButtonProps) {
- const renderText = useRenderMessage();
+ const { i18n } = useLingui();
const { openContactCalModal } = useContactCalModal();
return (
@@ -31,7 +31,7 @@ export function TalkToUsButton({ color, label, variant }: TalkToUsButtonProps) {
type="button"
onClick={openContactCalModal}
>
-
+
);
}
diff --git a/packages/twenty-website-new/src/lib/contact-cal/config.ts b/packages/twenty-website-new/src/sections/ContactCal/config.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/contact-cal/config.ts
rename to packages/twenty-website-new/src/sections/ContactCal/config.ts
diff --git a/packages/twenty-website-new/src/lib/contact-cal/index.ts b/packages/twenty-website-new/src/sections/ContactCal/index.ts
similarity index 100%
rename from packages/twenty-website-new/src/lib/contact-cal/index.ts
rename to packages/twenty-website-new/src/sections/ContactCal/index.ts
diff --git a/packages/twenty-website-new/src/sections/Demo/components/Screenshot.tsx b/packages/twenty-website-new/src/sections/Demo/components/Screenshot.tsx
index 38292aec8c..72734f3d4e 100644
--- a/packages/twenty-website-new/src/sections/Demo/components/Screenshot.tsx
+++ b/packages/twenty-website-new/src/sections/Demo/components/Screenshot.tsx
@@ -1,6 +1,10 @@
import type { ImageType } from '@/design-system/components/Image';
-import { theme } from '@/theme';
import { css } from '@linaria/core';
+
+const coverImageClassName = css`
+ object-fit: cover;
+`;
+import { theme } from '@/theme';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
@@ -20,10 +24,6 @@ const ScreenshotRoot = styled.div`
}
`;
-const screenshotImageClassName = css`
- object-fit: cover;
-`;
-
type ScreenshotProps = { image: ImageType };
export function Screenshot({ image }: ScreenshotProps) {
@@ -31,7 +31,7 @@ export function Screenshot({ image }: ScreenshotProps) {
`
- --body-paragraph-color: ${({ $color }) => $color};
- color: ${({ $color }) => $color};
+const bodyParagraphClassName = css`
+ color: var(--color-text-muted);
min-width: 0;
`;
@@ -46,45 +44,29 @@ const SingleColumnBody = styled.div`
`;
type EditorialBodyProps = {
- body: MessageBody | MessageBody[];
- color: string;
+ children: ReactNode;
layout:
| 'centered'
| 'indented'
| 'two-column'
| 'two-column-left'
| 'two-column-right';
- renderText: (descriptor: MessageDescriptor) => ReactNode;
};
-export function Body({ body, color, layout, renderText }: EditorialBodyProps) {
- const paragraphs = Array.isArray(body) ? (
- body.map((item, index) => (
-
-
-
- ))
- ) : (
-
+export function Body({ children, layout }: EditorialBodyProps) {
+ const paragraphs = Children.map(children, (child) => (
+
-
- );
+ >
+ {child}
+
+
+ ));
if (
layout === 'two-column' ||
diff --git a/packages/twenty-website-new/src/sections/Editorial/components/Eyebrow.tsx b/packages/twenty-website-new/src/sections/Editorial/components/Eyebrow.tsx
index 545e5a9d55..bb9e5d4b0b 100644
--- a/packages/twenty-website-new/src/sections/Editorial/components/Eyebrow.tsx
+++ b/packages/twenty-website-new/src/sections/Editorial/components/Eyebrow.tsx
@@ -1,24 +1,11 @@
import { Eyebrow as BaseEyebrow } from '@/design-system/components';
-import type { MessageEyebrow } from '@/lib/i18n/message-eyebrow';
-import type { MessageDescriptor } from '@lingui/core';
import type { ReactNode } from 'react';
type EditorialEyebrowProps = {
- colorScheme: 'primary' | 'secondary';
- eyebrow: MessageEyebrow;
- renderText: (descriptor: MessageDescriptor) => ReactNode;
+ children: ReactNode;
+ colorScheme?: 'primary' | 'secondary';
};
-export function Eyebrow({
- colorScheme,
- eyebrow,
- renderText,
-}: EditorialEyebrowProps) {
- return (
-
- );
+export function Eyebrow({ children, colorScheme }: EditorialEyebrowProps) {
+ return {children} ;
}
diff --git a/packages/twenty-website-new/src/sections/Editorial/components/Root.tsx b/packages/twenty-website-new/src/sections/Editorial/components/Root.tsx
index bc5e9da2b5..06ffc4c61d 100644
--- a/packages/twenty-website-new/src/sections/Editorial/components/Root.tsx
+++ b/packages/twenty-website-new/src/sections/Editorial/components/Root.tsx
@@ -1,5 +1,5 @@
import { Container, GuideCrosshair } from '@/design-system/components';
-import { theme } from '@/theme';
+import { theme, type Scheme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
@@ -10,6 +10,21 @@ const StyledSection = styled.section`
&[data-has-crosshair] {
position: relative;
}
+
+ &[data-scheme='light'] {
+ background-color: var(--color-white);
+ color: var(--color-text);
+ }
+
+ &[data-scheme='muted'] {
+ background-color: var(--color-neutral);
+ color: var(--color-text);
+ }
+
+ &[data-scheme='dark'] {
+ background-color: var(--color-black);
+ color: var(--color-text);
+ }
`;
const Inner = styled.div`
@@ -39,10 +54,11 @@ type EditorialCrosshair = {
};
type RootProps = {
- backgroundColor: string;
+ backgroundColor?: string;
children: ReactNode;
- color: string;
+ color?: string;
crosshair?: EditorialCrosshair;
+ scheme?: Scheme;
};
export function Root({
@@ -50,11 +66,13 @@ export function Root({
children,
color,
crosshair,
+ scheme,
}: RootProps) {
return (
{crosshair ? (
ReactNode;
+ children: ReactNode;
};
-export function Body({ body, renderText }: BodyProps) {
+export function Body({ children }: BodyProps) {
return (
-
+
+ {children}
+
);
}
diff --git a/packages/twenty-website-new/src/sections/EngagementBand/components/Heading.tsx b/packages/twenty-website-new/src/sections/EngagementBand/components/Heading.tsx
index a267944db8..0b5e89c0b5 100644
--- a/packages/twenty-website-new/src/sections/EngagementBand/components/Heading.tsx
+++ b/packages/twenty-website-new/src/sections/EngagementBand/components/Heading.tsx
@@ -1,8 +1,6 @@
import { Heading as BaseHeading } from '@/design-system/components';
import type { HeadingSize } from '@/design-system/components/Heading';
-import type { MessageHeadingSegment } from '@/lib/i18n/message-heading-segment';
import { theme } from '@/theme';
-import type { MessageDescriptor } from '@lingui/core';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
@@ -15,18 +13,14 @@ const StyledHeading = styled(BaseHeading)`
`;
type HeadingProps = {
- segments: MessageHeadingSegment;
- renderText: (descriptor: MessageDescriptor) => ReactNode;
+ children: ReactNode;
size?: HeadingSize;
};
-export function Heading({ segments, renderText, size = 'sm' }: HeadingProps) {
+export function Heading({ children, size = 'sm' }: HeadingProps) {
return (
-
+
+ {children}
+
);
}
diff --git a/packages/twenty-website-new/src/sections/EngagementBand/components/Root.tsx b/packages/twenty-website-new/src/sections/EngagementBand/components/Root.tsx
index baf44a0995..06dd5a525c 100644
--- a/packages/twenty-website-new/src/sections/EngagementBand/components/Root.tsx
+++ b/packages/twenty-website-new/src/sections/EngagementBand/components/Root.tsx
@@ -1,5 +1,5 @@
import { Container } from '@/design-system/components';
-import { theme } from '@/theme';
+import { theme, type Scheme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
@@ -7,6 +7,18 @@ const StyledSection = styled.section<{ compactBottom: boolean }>`
padding-bottom: ${({ compactBottom }) =>
compactBottom ? theme.spacing(6) : theme.spacing(20)};
width: 100%;
+
+ &[data-scheme='light'] {
+ background-color: var(--color-white);
+ }
+
+ &[data-scheme='muted'] {
+ background-color: var(--color-neutral);
+ }
+
+ &[data-scheme='dark'] {
+ background-color: var(--color-black);
+ }
`;
const StyledContainer = styled(Container)`
@@ -23,18 +35,24 @@ const StyledContainer = styled(Container)`
`;
type RootProps = {
- backgroundColor: string;
+ backgroundColor?: string;
children: ReactNode;
compactBottom?: boolean;
+ scheme?: Scheme;
};
export function Root({
backgroundColor,
children,
compactBottom = false,
+ scheme,
}: RootProps) {
return (
-
+
{children}
);
diff --git a/packages/twenty-website-new/src/sections/EngagementBand/components/Strip.tsx b/packages/twenty-website-new/src/sections/EngagementBand/components/Strip.tsx
index 1d5d9feefd..f65520ab59 100644
--- a/packages/twenty-website-new/src/sections/EngagementBand/components/Strip.tsx
+++ b/packages/twenty-website-new/src/sections/EngagementBand/components/Strip.tsx
@@ -5,8 +5,8 @@ import NextImage from 'next/image';
import type { ReactNode } from 'react';
const OVERLAY_SRC_BY_TONE = {
- white: '/images/shared/engagement-band/halftone-on-white.png',
- gray: '/images/shared/engagement-band/halftone-on-gray.png',
+ white: '/images/shared/engagement-band/halftone-on-white.webp',
+ gray: '/images/shared/engagement-band/halftone-on-gray.webp',
} as const;
type StripTone = keyof typeof OVERLAY_SRC_BY_TONE;
diff --git a/packages/twenty-website-new/src/sections/EngagementBand/index.ts b/packages/twenty-website-new/src/sections/EngagementBand/index.ts
new file mode 100644
index 0000000000..474ae3024b
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/EngagementBand/index.ts
@@ -0,0 +1 @@
+export { EngagementBand } from './components';
diff --git a/packages/twenty-website-new/src/sections/Faq/components/Root.tsx b/packages/twenty-website-new/src/sections/Faq/Faq.tsx
similarity index 52%
rename from packages/twenty-website-new/src/sections/Faq/components/Root.tsx
rename to packages/twenty-website-new/src/sections/Faq/Faq.tsx
index 3b6d083668..17d9222404 100644
--- a/packages/twenty-website-new/src/sections/Faq/components/Root.tsx
+++ b/packages/twenty-website-new/src/sections/Faq/Faq.tsx
@@ -1,7 +1,9 @@
-import { Container } from '@/design-system/components';
+import { Container, Heading as BaseHeading } from '@/design-system/components';
import { WebGlMount } from '@/lib/visual-runtime';
import { FaqBackground } from '@/sections/Faq/visuals/Background';
+import { FaqItems } from '@/sections/Faq/FaqItems';
import { theme } from '@/theme';
+import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
@@ -39,13 +41,34 @@ const StyledContainer = styled(Container)`
}
`;
-type RootProps = {
+const StyledIntro = styled.div`
+ display: grid;
+ grid-template-columns: 1fr;
+ row-gap: ${theme.spacing(6)};
+`;
+
+const faqHeadingClassName = css`
+ white-space: pre-line;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ max-width: 700px;
+ }
+`;
+
+const StyledCta = styled.div`
+ column-gap: ${theme.spacing(2)};
+ display: grid;
+ grid-template-columns: auto auto;
+ justify-content: start;
+`;
+
+type ChildrenProps = {
children: ReactNode;
};
-export function Root({ children }: RootProps) {
+function Root({ children }: ChildrenProps) {
return (
-
+
@@ -55,3 +78,32 @@ export function Root({ children }: RootProps) {
);
}
+
+function Intro({ children }: ChildrenProps) {
+ return {children} ;
+}
+
+function Heading({ children }: ChildrenProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Cta({ children }: ChildrenProps) {
+ return {children} ;
+}
+
+export const Faq = {
+ Cta,
+ Heading,
+ Intro,
+ Items: FaqItems,
+ Root,
+};
diff --git a/packages/twenty-website-new/src/sections/Faq/components/Item.tsx b/packages/twenty-website-new/src/sections/Faq/FaqItems.tsx
similarity index 86%
rename from packages/twenty-website-new/src/sections/Faq/components/Item.tsx
rename to packages/twenty-website-new/src/sections/Faq/FaqItems.tsx
index 000af100f8..734701c74a 100644
--- a/packages/twenty-website-new/src/sections/Faq/components/Item.tsx
+++ b/packages/twenty-website-new/src/sections/Faq/FaqItems.tsx
@@ -1,17 +1,28 @@
'use client';
+import { Accordion as BaseAccordion } from '@base-ui/react/accordion';
import {
MinusIcon,
PlusIcon,
RectangleFillIcon,
RectangleOutlineIcon,
} from '@/icons';
-import type { FaqQuestionType } from '@/sections/Faq/types/FaqQuestion';
-import { useRenderMessage } from '@/lib/i18n/use-render-message';
+import type { FaqQuestionType } from '@/sections/Faq/faq.data';
+import { useLingui } from '@lingui/react';
import { theme } from '@/theme';
-import { Accordion as BaseAccordion } from '@base-ui/react/accordion';
import { styled } from '@linaria/react';
+const AccordionList = styled.div`
+ margin-bottom: 0;
+ margin-left: auto;
+ margin-right: auto;
+ margin-top: 0;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ width: 66%;
+ }
+`;
+
const QuestionText = styled.span`
color: ${theme.colors.secondary.text[40]};
font-size: 2rem;
@@ -118,7 +129,7 @@ const RowTrigger = styled.button`
}
`;
-export const ItemRow = styled.div`
+const ItemRow = styled.div`
border-top: 1px solid ${theme.colors.secondary.text[40]};
display: grid;
grid-template-columns: 14px 12px 1fr 32px 36px;
@@ -207,8 +218,8 @@ type ItemProps = {
value: string;
};
-export function Item({ question, value }: ItemProps) {
- const renderText = useRenderMessage();
+function Item({ question, value }: ItemProps) {
+ const { i18n } = useLingui();
return (
}>
@@ -229,7 +240,7 @@ export function Item({ question, value }: ItemProps) {
- {renderText(question.question.text)}
+ {i18n._(question.question)}
@@ -252,9 +263,23 @@ export function Item({ question, value }: ItemProps) {
} keepMounted>
- {renderText(question.answer.text)}
+ {i18n._(question.answer)}
);
}
+
+type FaqItemsProps = {
+ questions: FaqQuestionType[];
+};
+
+export function FaqItems({ questions }: FaqItemsProps) {
+ return (
+ }>
+ {questions.map((faqQuestion, index) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Faq/components/Cta.tsx b/packages/twenty-website-new/src/sections/Faq/components/Cta.tsx
deleted file mode 100644
index 3a65dd915b..0000000000
--- a/packages/twenty-website-new/src/sections/Faq/components/Cta.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { theme } from '@/theme';
-import { styled } from '@linaria/react';
-import type { ReactNode } from 'react';
-
-const StyledCta = styled.div`
- column-gap: ${theme.spacing(2)};
- display: grid;
- grid-template-columns: auto auto;
- justify-content: start;
-`;
-
-type CtaProps = {
- children: ReactNode;
-};
-
-export function Cta({ children }: CtaProps) {
- return {children} ;
-}
diff --git a/packages/twenty-website-new/src/sections/Faq/components/Heading.tsx b/packages/twenty-website-new/src/sections/Faq/components/Heading.tsx
deleted file mode 100644
index 1b585524de..0000000000
--- a/packages/twenty-website-new/src/sections/Faq/components/Heading.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Heading as BaseHeading } from '@/design-system/components';
-import { theme } from '@/theme';
-import { css } from '@linaria/core';
-import type { ReactNode } from 'react';
-
-const faqHeadingClassName = css`
- white-space: pre-line;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- max-width: 700px;
- }
-`;
-
-type HeadingProps = {
- children: ReactNode;
-};
-
-export function Heading({ children }: HeadingProps) {
- return (
-
- {children}
-
- );
-}
diff --git a/packages/twenty-website-new/src/sections/Faq/components/Intro.tsx b/packages/twenty-website-new/src/sections/Faq/components/Intro.tsx
deleted file mode 100644
index b73d532ca7..0000000000
--- a/packages/twenty-website-new/src/sections/Faq/components/Intro.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { theme } from '@/theme';
-import { styled } from '@linaria/react';
-import type { ReactNode } from 'react';
-
-const StyledIntro = styled.div`
- display: grid;
- grid-template-columns: 1fr;
- row-gap: ${theme.spacing(6)};
-`;
-
-type IntroProps = {
- children: ReactNode;
-};
-
-export function Intro({ children }: IntroProps) {
- return {children} ;
-}
diff --git a/packages/twenty-website-new/src/sections/Faq/components/Items.tsx b/packages/twenty-website-new/src/sections/Faq/components/Items.tsx
deleted file mode 100644
index 056e4ce10b..0000000000
--- a/packages/twenty-website-new/src/sections/Faq/components/Items.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-'use client';
-
-import { Accordion as BaseAccordion } from '@base-ui/react/accordion';
-import { theme } from '@/theme';
-import { styled } from '@linaria/react';
-import type { FaqQuestionType } from '@/sections/Faq/types/FaqQuestion';
-import { Item } from './Item';
-
-type ItemsProps = {
- questions: FaqQuestionType[];
-};
-
-const AccordionList = styled.div`
- margin-bottom: 0;
- margin-left: auto;
- margin-right: auto;
- margin-top: 0;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- width: 66%;
- }
-`;
-
-export function Items({ questions }: ItemsProps) {
- return (
- }>
- {questions.map((faqQuestion, index) => (
-
- ))}
-
- );
-}
diff --git a/packages/twenty-website-new/src/sections/Faq/components/index.ts b/packages/twenty-website-new/src/sections/Faq/components/index.ts
deleted file mode 100644
index 8cfe5ef227..0000000000
--- a/packages/twenty-website-new/src/sections/Faq/components/index.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Cta } from './Cta';
-import { Heading } from './Heading';
-import { Intro } from './Intro';
-import { Items } from './Items';
-import { Root } from './Root';
-
-export const Faq = {
- Cta,
- Heading,
- Intro,
- Items,
- Root,
-};
diff --git a/packages/twenty-website-new/src/sections/Faq/data.ts b/packages/twenty-website-new/src/sections/Faq/data.ts
deleted file mode 100644
index 7c73b96e06..0000000000
--- a/packages/twenty-website-new/src/sections/Faq/data.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { msg } from '@lingui/core/macro';
-import type { FaqDataType } from '@/sections/Faq/types';
-
-export const FAQ_DATA: FaqDataType = {
- eyebrow: {
- heading: {
- text: msg`Any Questions?`,
- fontFamily: 'sans',
- },
- },
- questions: [
- {
- question: {
- text: msg`Is Twenty really open-source?`,
- fontFamily: 'sans',
- },
- answer: {
- text: msg`Yes. Twenty is the #1 Open Source CRM on GitHub. You can self-host to fully own your infrastructure, or run it on our managed cloud for a zero-ops setup.`,
- },
- },
- {
- question: {
- text: msg`How long does it take to get started?`,
- fontFamily: 'sans',
- },
- answer: {
- text: msg`Sign up for Cloud in under a minute and start your 30-day trial. For larger rollouts, our 4-hour Onboarding Packs or certified partners get you live in 1–2 weeks.`,
- },
- },
- {
- question: {
- text: msg`Can I migrate from Salesforce or HubSpot?`,
- fontFamily: 'sans',
- },
- answer: {
- text: msg`Yes. Import your data via CSV, or use our API for 50,000+ records. Our partners can handle the full migration for you.`,
- },
- },
- {
- question: {
- text: msg`Do I need a developer to customize Twenty?`,
- fontFamily: 'sans',
- },
- answer: {
- text: msg`No. Build custom objects, fields, views, and no-code workflows straight from Settings. Unlimited, no extra charge.`,
- },
- },
- {
- question: {
- text: msg`Can developers extend Twenty with code?`,
- fontFamily: 'sans',
- },
- answer: {
- text: msg`Yes, with our Apps framework. Scaffold an extension with \`npx create-twenty-app\` and ship custom objects, server-side logic functions, React components that render inside Twenty\u2019s UI, AI skills and agents, views, and navigation, all in TypeScript, deployable to any workspace.`,
- },
- },
- {
- question: {
- text: msg`Does Twenty work with Claude, ChatGPT, and Cursor?`,
- fontFamily: 'sans',
- },
- answer: {
- text: msg`Yes. Every Cloud workspace ships with a native MCP server. Connect your AI assistant via OAuth and it can read and write your CRM data in natural language.`,
- },
- },
- {
- question: {
- text: msg`What does Twenty cost?`,
- fontFamily: 'sans',
- },
- answer: {
- text: msg`Cloud Pro is $9/user/month (yearly). Organization is $19/user/month and unlocks SSO and row-level permissions for teams that need finer access control.`,
- },
- },
- ],
-};
diff --git a/packages/twenty-website-new/src/sections/Faq/faq.data.ts b/packages/twenty-website-new/src/sections/Faq/faq.data.ts
new file mode 100644
index 0000000000..fae0454dd2
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Faq/faq.data.ts
@@ -0,0 +1,38 @@
+import { msg } from '@lingui/core/macro';
+import type { MessageDescriptor } from '@lingui/core';
+
+export type FaqQuestionType = {
+ question: MessageDescriptor;
+ answer: MessageDescriptor;
+};
+
+export const FAQ_QUESTIONS: FaqQuestionType[] = [
+ {
+ question: msg`Is Twenty really open-source?`,
+ answer: msg`Yes. Twenty is the #1 Open Source CRM on GitHub. You can self-host to fully own your infrastructure, or run it on our managed cloud for a zero-ops setup.`,
+ },
+ {
+ question: msg`How long does it take to get started?`,
+ answer: msg`Sign up for Cloud in under a minute and start your 30-day trial. For larger rollouts, our 4-hour Onboarding Packs or certified partners get you live in 1–2 weeks.`,
+ },
+ {
+ question: msg`Can I migrate from Salesforce or HubSpot?`,
+ answer: msg`Yes. Import your data via CSV, or use our API for 50,000+ records. Our partners can handle the full migration for you.`,
+ },
+ {
+ question: msg`Do I need a developer to customize Twenty?`,
+ answer: msg`No. Build custom objects, fields, views, and no-code workflows straight from Settings. Unlimited, no extra charge.`,
+ },
+ {
+ question: msg`Can developers extend Twenty with code?`,
+ answer: msg`Yes, with our Apps framework. Scaffold an extension with \`npx create-twenty-app\` and ship custom objects, server-side logic functions, React components that render inside Twenty\u2019s UI, AI skills and agents, views, and navigation, all in TypeScript, deployable to any workspace.`,
+ },
+ {
+ question: msg`Does Twenty work with Claude, ChatGPT, and Cursor?`,
+ answer: msg`Yes. Every Cloud workspace ships with a native MCP server. Connect your AI assistant via OAuth and it can read and write your CRM data in natural language.`,
+ },
+ {
+ question: msg`What does Twenty cost?`,
+ answer: msg`Cloud Pro is $9/user/month (yearly). Organization is $19/user/month and unlocks SSO and row-level permissions for teams that need finer access control.`,
+ },
+];
diff --git a/packages/twenty-website-new/src/sections/Faq/index.ts b/packages/twenty-website-new/src/sections/Faq/index.ts
new file mode 100644
index 0000000000..d444ce801e
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Faq/index.ts
@@ -0,0 +1,2 @@
+export { Faq } from './Faq';
+export { FAQ_QUESTIONS } from './faq.data';
diff --git a/packages/twenty-website-new/src/sections/Faq/types/FaqData.ts b/packages/twenty-website-new/src/sections/Faq/types/FaqData.ts
deleted file mode 100644
index 5b2b1f2ccf..0000000000
--- a/packages/twenty-website-new/src/sections/Faq/types/FaqData.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { MessageEyebrow } from '@/lib/i18n/message-eyebrow';
-import { type FaqQuestionType } from '@/sections/Faq/types/FaqQuestion';
-
-export type FaqDataType = {
- eyebrow: MessageEyebrow;
- questions: FaqQuestionType[];
-};
diff --git a/packages/twenty-website-new/src/sections/Faq/types/FaqQuestion.ts b/packages/twenty-website-new/src/sections/Faq/types/FaqQuestion.ts
deleted file mode 100644
index 4bcf494bd0..0000000000
--- a/packages/twenty-website-new/src/sections/Faq/types/FaqQuestion.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { MessageBody } from '@/lib/i18n/message-body';
-import type { MessageHeadingSegment } from '@/lib/i18n/message-heading-segment';
-
-export type FaqQuestionType = {
- question: MessageHeadingSegment;
- answer: MessageBody;
-};
diff --git a/packages/twenty-website-new/src/sections/Faq/types/index.ts b/packages/twenty-website-new/src/sections/Faq/types/index.ts
deleted file mode 100644
index 08f9220111..0000000000
--- a/packages/twenty-website-new/src/sections/Faq/types/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export type { FaqDataType } from './FaqData';
-export type { FaqQuestionType } from './FaqQuestion';
diff --git a/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx b/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx
index 45d1b39baa..adc25b4951 100644
--- a/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx
+++ b/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx
@@ -1,331 +1,10 @@
'use client';
-import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
-import { loadImportedGeometryFromUrl } from '@/lib/halftone';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
-import { useEffect, useLayoutEffect, useRef } from 'react';
-import * as THREE from 'three';
+import { useLayoutEffect, useRef } from 'react';
import { createAnimationFrameLoop } from '@/lib/animation';
-import { observeElementSize } from '@/lib/dom/observe-element-size';
-import {
- createVisualRenderLoop,
- tryCreateSiteWebGlRenderer,
- type VisualRenderLoop,
- type VisualRenderLoopFrame,
-} from '@/lib/visual-runtime';
-
-const VIRTUAL_RENDER_HEIGHT = 768;
-
-const passThroughVertexShader = /* glsl */ `
- varying vec2 vUv;
-
- void main() {
- vUv = uv;
- gl_Position = vec4(position, 1.0);
- }
-`;
-
-const blurFragmentShader = /* glsl */ `
- precision highp float;
-
- uniform sampler2D tInput;
- uniform vec2 dir;
- uniform vec2 res;
-
- varying vec2 vUv;
-
- void main() {
- vec4 sum = vec4(0.0);
- vec2 px = dir / res;
-
- float w[5];
- w[0] = 0.227027;
- w[1] = 0.1945946;
- w[2] = 0.1216216;
- w[3] = 0.054054;
- w[4] = 0.016216;
-
- sum += texture2D(tInput, vUv) * w[0];
-
- for (int i = 1; i < 5; i++) {
- float fi = float(i) * 3.0;
- sum += texture2D(tInput, vUv + px * fi) * w[i];
- sum += texture2D(tInput, vUv - px * fi) * w[i];
- }
-
- gl_FragColor = sum;
- }
-`;
-
-const halftoneFragmentShader = /* glsl */ `
- precision highp float;
-
- uniform sampler2D tScene;
- uniform sampler2D tGlow;
- uniform vec2 resolution;
- uniform float numRows;
- uniform float glowStr;
- uniform float contrast;
- uniform float power;
- uniform float shading;
- uniform float baseInk;
- uniform float maxBar;
- uniform float rowMerge;
- uniform float cellRatio;
- uniform float cutoff;
- uniform float highlightOpen;
- uniform float shadowGrouping;
- uniform float shadowCrush;
- uniform vec3 dashColor;
- uniform float time;
- uniform float waveAmount;
- uniform float waveSpeed;
- uniform float distanceScale;
- uniform vec2 interactionUv;
- uniform vec2 interactionVelocity;
- uniform vec2 dragOffset;
- uniform float hoverLightStrength;
- uniform float hoverLightRadius;
- uniform float hoverFlowStrength;
- uniform float hoverFlowRadius;
- uniform float dragFlowStrength;
- uniform float dragFlowRadius;
- uniform float cropToBounds;
-
- varying vec2 vUv;
-
- void main() {
- // Crop to image bounds: discard fragments outside source image (image mode only)
- if (cropToBounds > 0.5) {
- vec4 boundsCheck = texture2D(tScene, vUv);
- if (boundsCheck.a < 0.01) {
- gl_FragColor = vec4(0.0);
- return;
- }
- }
-
- float baseRowH = resolution.y / (numRows * distanceScale);
- vec2 pointerPx = interactionUv * resolution;
- vec2 fragDelta = gl_FragCoord.xy - pointerPx;
- float fragDist = length(fragDelta);
- vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
- float velocityMagnitude = length(interactionVelocity);
- vec2 motionDir = velocityMagnitude > 0.001
- ? interactionVelocity / velocityMagnitude
- : vec2(0.0, 0.0);
- float motionBias = velocityMagnitude > 0.001
- ? dot(-radialDir, motionDir) * 0.5 + 0.5
- : 0.5;
-
- float hoverLightMask = 0.0;
- if (hoverLightStrength > 0.0) {
- float lightRadiusPx = hoverLightRadius * resolution.y;
- hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
- }
-
- float hoverFlowMask = 0.0;
- if (hoverFlowStrength > 0.0) {
- float hoverRadiusPx = hoverFlowRadius * resolution.y;
- hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
- }
-
- float dragFlowMask = 0.0;
- if (dragFlowStrength > 0.0) {
- float dragRadiusPx = dragFlowRadius * resolution.y;
- dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist);
- }
-
- vec2 hoverDisplacement =
- radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 +
- motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15;
- vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8;
- vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement;
-
- float densityBoost =
- hoverFlowStrength * hoverFlowMask * 0.22 +
- dragFlowStrength * dragFlowMask * 0.16;
- float rowH = baseRowH / (1.0 + densityBoost);
-
- float offsetY = effectCoord.y;
- float row = floor(offsetY / rowH);
- float rowFrac = offsetY / rowH - row;
- float rowV = (row + 0.5) * rowH / resolution.y;
- float dy = abs(rowFrac - 0.5);
-
- float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH;
- float effectiveX = effectCoord.x + waveOffset;
-
- float localCellRatio = cellRatio * (
- 1.0 +
- hoverFlowStrength * hoverFlowMask * 0.08 +
- dragFlowStrength * dragFlowMask * 0.1 * motionBias
- );
- float cellW = rowH * localCellRatio;
- float cellIdx = floor(effectiveX / cellW);
- float cellFrac = (effectiveX - cellIdx * cellW) / cellW;
- float cellU = (cellIdx + 0.5) * cellW / resolution.x;
-
- vec2 sampleUv = vec2(
- clamp(cellU, 0.0, 1.0),
- clamp(rowV, 0.0, 1.0)
- );
-
- vec4 sceneSample = texture2D(tScene, sampleUv);
- vec4 glowCell = texture2D(tGlow, sampleUv);
-
- float mask = smoothstep(0.02, 0.08, sceneSample.a);
- float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114));
- float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114));
- float detail = lum - avgLum;
-
- float litLum = lum + max(detail, 0.0) * shading
- - max(-detail, 0.0) * shading * 0.55;
- float lightLift =
- hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34;
- float lightFocus = hoverLightStrength * hoverLightMask * 0.12;
- litLum = clamp(litLum + lightLift, 0.0, 1.0);
- litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
- litLum = pow(litLum, max(contrast - lightFocus, 0.25));
-
- float darkness = 1.0 - litLum;
- float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
- groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25));
- float groupedDarkness = 1.0 - groupedLum;
- darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping);
- darkness = clamp(
- (darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001),
- 0.0,
- 1.0
- );
-
- float shadowMask = smoothstep(0.42, 0.96, darkness);
- darkness = mix(
- darkness,
- mix(darkness, 1.0, shadowMask),
- shadowCrush
- );
-
- float inkBase = baseInk * smoothstep(0.03, 0.24, darkness);
- float ink = mix(inkBase, 1.0, darkness);
- float fill = pow(ink, 1.05) * power;
- fill = clamp(fill, 0.0, 1.0) * mask;
-
- float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink));
- float dynamicBarHalfY = min(
- dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink),
- 0.78
- );
- float dx2 = abs(cellFrac - 0.5);
- float halfFill = fill * 0.5;
- float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
- float capRX = dynamicBarHalf * rowH;
- float capRY = dynamicBarHalfY * rowH;
-
- float inDash = 0.0;
- if (dx2 <= bodyHalfW) {
- float edgeDist = dynamicBarHalfY - dy;
- inDash = smoothstep(-0.03, 0.03, edgeDist);
- } else {
- float cdx = (dx2 - bodyHalfW) * cellW;
- float cdy = dy * rowH;
- float ellipseDist = sqrt(
- (cdx * cdx) / max(capRX * capRX, 0.0001) +
- (cdy * cdy) / max(capRY * capRY, 0.0001)
- );
- inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist);
- }
-
- inDash *= step(0.001, ink) * mask;
- inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1);
-
- vec4 glow = texture2D(tGlow, vUv);
- float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114));
- float halo = glowLum * glowStr * 0.25 * (1.0 - inDash);
- float sharp = smoothstep(0.3, 0.5, inDash + halo);
- vec3 color = dashColor * sharp;
-
- gl_FragColor = vec4(color, sharp);
-
- #include
- #include
- }
-`;
-
-const GLB_URL = '/illustrations/common/faq/faq.glb';
-
-const LIGHT_INTENSITY = 3.1;
-const FILL_LIGHT_INTENSITY = 0.15;
-const AMBIENT_LIGHT_INTENSITY = 0.08;
-const LIGHT_ANGLE_DEGREES = 45;
-const LIGHT_HEIGHT = 2;
-
-const MATERIAL_ROUGHNESS = 0.42;
-const MATERIAL_METALNESS = 0.16;
-
-const HALFTONE_ROWS = 127;
-const HALFTONE_CONTRAST = 1.6;
-const HALFTONE_POWER = 1.2;
-const HALFTONE_SHADING = 1.6;
-const HALFTONE_BASE_INK = 0.16;
-const HALFTONE_MAX_BAR = 0.24;
-const HALFTONE_CELL_RATIO = 2.2;
-const HALFTONE_CUTOFF = 0.02;
-const HALFTONE_DASH_COLOR = '#4A38F5';
-
-const ROTATE_AXIS: 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy' = '-z';
-const ROTATE_SPEED = 0.1;
-const ROTATE_PING_PONG = false;
-const INITIAL_ROTATE_ELAPSED = 275.10000000007847;
-const INITIAL_TIME_ELAPSED = 249.21849999998116;
-const BASE_CAMERA_DISTANCE = 4;
-const MODEL_OFFSET_Y = 0.52;
-
-function createEnvironmentTexture(renderer: THREE.WebGLRenderer) {
- const pmremGenerator = new THREE.PMREMGenerator(renderer);
- const environmentTexture = pmremGenerator.fromScene(
- new RoomEnvironment(),
- 0.04,
- ).texture;
- pmremGenerator.dispose();
-
- return environmentTexture;
-}
-
-function createRenderTarget(width: number, height: number) {
- return new THREE.WebGLRenderTarget(width, height, {
- minFilter: THREE.LinearFilter,
- magFilter: THREE.LinearFilter,
- format: THREE.RGBAFormat,
- });
-}
-
-function setPrimaryLightPosition(
- light: THREE.DirectionalLight,
- angleDegrees: number,
- height: number,
-) {
- const lightAngle = (angleDegrees * Math.PI) / 180;
- light.position.set(
- Math.cos(lightAngle) * 5,
- height,
- Math.sin(lightAngle) * 5,
- );
-}
-
-function createFaqMaterial(environmentTexture: THREE.Texture) {
- return new THREE.MeshPhysicalMaterial({
- color: 0xd4d0c8,
- roughness: MATERIAL_ROUGHNESS,
- metalness: MATERIAL_METALNESS,
- envMap: environmentTexture,
- envMapIntensity: 0.25,
- clearcoat: 0,
- clearcoatRoughness: 0.08,
- reflectivity: 0.5,
- transmission: 0,
- });
-}
+import { useFaqBackground } from './use-faq-background';
const FaqVisualShell = styled.div`
bottom: 0;
@@ -399,325 +78,7 @@ export function FaqBackground() {
};
}, []);
- useEffect(() => {
- const container = mountReference.current;
-
- if (!container) {
- return;
- }
-
- let cancelled = false;
- let renderLoop: VisualRenderLoop | null = null;
- let rotateElapsed = INITIAL_ROTATE_ELAPSED;
-
- 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 = tryCreateSiteWebGlRenderer({
- antialias: false,
- alpha: true,
- onContextLost: () => {
- renderLoop?.stop();
- },
- });
-
- if (renderer === null) {
- return;
- }
-
- renderer.outputColorSpace = THREE.SRGBColorSpace;
- renderer.setPixelRatio(1);
- renderer.setClearColor(0x000000, 0);
- renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
-
- const canvas = renderer.domElement;
- canvas.style.display = 'block';
- canvas.style.height = '100%';
- canvas.style.width = '100%';
- container.appendChild(canvas);
-
- const environmentTexture = createEnvironmentTexture(renderer);
- const scene3d = new THREE.Scene();
- scene3d.background = null;
-
- const camera = new THREE.PerspectiveCamera(
- 45,
- getWidth() / getHeight(),
- 0.1,
- 100,
- );
- camera.position.z = BASE_CAMERA_DISTANCE;
-
- const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHT_INTENSITY);
- setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT);
- scene3d.add(primaryLight);
-
- const fillLight = new THREE.DirectionalLight(
- 0xffffff,
- FILL_LIGHT_INTENSITY,
- );
- fillLight.position.set(-3, -1, 1);
- scene3d.add(fillLight);
-
- const ambientLight = new THREE.AmbientLight(
- 0xffffff,
- AMBIENT_LIGHT_INTENSITY,
- );
- scene3d.add(ambientLight);
-
- const material = createFaqMaterial(environmentTexture);
- const mesh = new THREE.Mesh(new THREE.BufferGeometry(), material);
- mesh.visible = false;
- scene3d.add(mesh);
-
- const sceneTarget = createRenderTarget(
- getVirtualWidth(),
- getVirtualHeight(),
- );
- const blurTargetA = createRenderTarget(
- getVirtualWidth(),
- getVirtualHeight(),
- );
- const blurTargetB = createRenderTarget(
- getVirtualWidth(),
- getVirtualHeight(),
- );
- const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
- const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
-
- const blurHorizontalMaterial = new THREE.ShaderMaterial({
- uniforms: {
- tInput: { value: null },
- dir: { value: new THREE.Vector2(1, 0) },
- res: {
- value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
- },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: blurFragmentShader,
- });
-
- const blurVerticalMaterial = new THREE.ShaderMaterial({
- uniforms: {
- tInput: { value: null },
- dir: { value: new THREE.Vector2(0, 1) },
- res: {
- value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
- },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: blurFragmentShader,
- });
-
- const halftoneMaterial = new THREE.ShaderMaterial({
- transparent: true,
- uniforms: {
- tScene: { value: sceneTarget.texture },
- tGlow: { value: blurTargetB.texture },
- resolution: {
- value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
- },
- numRows: { value: HALFTONE_ROWS },
- glowStr: { value: 0 },
- contrast: { value: HALFTONE_CONTRAST },
- power: { value: HALFTONE_POWER },
- shading: { value: HALFTONE_SHADING },
- baseInk: { value: HALFTONE_BASE_INK },
- maxBar: { value: HALFTONE_MAX_BAR },
- cellRatio: { value: HALFTONE_CELL_RATIO },
- cutoff: { value: HALFTONE_CUTOFF },
- dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) },
- time: { value: 0 },
- waveAmount: { value: 0 },
- waveSpeed: { value: 1 },
- distanceScale: { value: 1 },
- interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
- interactionVelocity: { value: new THREE.Vector2(0, 0) },
- dragOffset: { value: new THREE.Vector2(0, 0) },
- hoverLightStrength: { value: 0 },
- hoverLightRadius: { value: 0.2 },
- hoverFlowStrength: { value: 0 },
- hoverFlowRadius: { value: 0.18 },
- dragFlowStrength: { value: 0 },
- dragFlowRadius: { value: 0.24 },
- cropToBounds: { value: 0 },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: halftoneFragmentShader,
- });
-
- const blurHorizontalScene = new THREE.Scene();
- blurHorizontalScene.add(
- new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
- );
-
- const blurVerticalScene = new THREE.Scene();
- blurVerticalScene.add(
- new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial),
- );
-
- const postScene = new THREE.Scene();
- postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
-
- const applySize = () => {
- const width = getWidth();
- const height = getHeight();
- const virtualWidth = getVirtualWidth();
- const virtualHeight = getVirtualHeight();
-
- renderer.setSize(virtualWidth, virtualHeight, false);
- camera.aspect = width / height;
- camera.updateProjectionMatrix();
- sceneTarget.setSize(virtualWidth, virtualHeight);
- blurTargetA.setSize(virtualWidth, virtualHeight);
- blurTargetB.setSize(virtualWidth, virtualHeight);
- blurHorizontalMaterial.uniforms.res.value.set(
- virtualWidth,
- virtualHeight,
- );
- blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
- halftoneMaterial.uniforms.resolution.value.set(
- virtualWidth,
- virtualHeight,
- );
- };
-
- applySize();
- const stopObservingSize = observeElementSize(container, applySize);
-
- loadImportedGeometryFromUrl('glb', GLB_URL, 'FAQ model')
- .then((geometry) => {
- if (cancelled) {
- geometry.dispose();
- return;
- }
-
- mesh.geometry.dispose();
- mesh.geometry = geometry;
- mesh.visible = true;
- })
- .catch((error) => {
- console.error(error);
- });
-
- const renderFrame = (
- _timestamp: DOMHighResTimeStamp,
- { elapsedSeconds }: VisualRenderLoopFrame,
- ) => {
- if (cancelled) {
- return;
- }
-
- const delta = 1 / 60;
- const elapsedTime = INITIAL_TIME_ELAPSED + elapsedSeconds;
- halftoneMaterial.uniforms.time.value = elapsedTime;
-
- rotateElapsed += delta;
-
- const rotateProgress = ROTATE_PING_PONG
- ? Math.sin(rotateElapsed * ROTATE_SPEED) * Math.PI
- : rotateElapsed * ROTATE_SPEED;
- const axisDirection = ROTATE_AXIS.startsWith('-') ? -1 : 1;
- const axisProgress = rotateProgress * axisDirection;
-
- let rotationX = 0;
- let rotationY = 0;
- let rotationZ = 0;
-
- if (
- ROTATE_AXIS === 'x' ||
- ROTATE_AXIS === 'xy' ||
- ROTATE_AXIS === '-x' ||
- ROTATE_AXIS === '-xy'
- ) {
- rotationX += axisProgress;
- }
-
- if (
- ROTATE_AXIS === 'y' ||
- ROTATE_AXIS === 'xy' ||
- ROTATE_AXIS === '-y' ||
- ROTATE_AXIS === '-xy'
- ) {
- rotationY += axisProgress;
- }
-
- if (ROTATE_AXIS === 'z' || ROTATE_AXIS === '-z') {
- rotationZ += axisProgress;
- }
-
- mesh.rotation.set(rotationX, rotationY, rotationZ);
- mesh.position.y = MODEL_OFFSET_Y;
- mesh.scale.setScalar(1);
-
- camera.position.x += (0 - camera.position.x) * 0.12;
- camera.position.y += (0 - camera.position.y) * 0.12;
- camera.position.z += (BASE_CAMERA_DISTANCE - camera.position.z) * 0.12;
- camera.lookAt(0, MODEL_OFFSET_Y * 0.2, 0);
- setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT);
-
- renderer.setRenderTarget(sceneTarget);
- renderer.clear();
- renderer.render(scene3d, camera);
-
- blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
- renderer.setRenderTarget(blurTargetA);
- renderer.render(blurHorizontalScene, orthographicCamera);
-
- blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
- renderer.setRenderTarget(blurTargetB);
- renderer.render(blurVerticalScene, orthographicCamera);
-
- blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
- renderer.setRenderTarget(blurTargetA);
- renderer.render(blurHorizontalScene, orthographicCamera);
-
- blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
- renderer.setRenderTarget(blurTargetB);
- renderer.render(blurVerticalScene, orthographicCamera);
-
- renderer.setRenderTarget(null);
- renderer.clear();
- renderer.render(postScene, orthographicCamera);
- };
-
- renderLoop = createVisualRenderLoop({
- renderFrame,
- shouldRender: () => !cancelled,
- target: container,
- targetVisibilityOptions: { rootMargin: '100px' },
- });
- renderLoop.start();
-
- return () => {
- cancelled = true;
- renderLoop?.dispose();
- stopObservingSize();
- blurHorizontalMaterial.dispose();
- blurVerticalMaterial.dispose();
- halftoneMaterial.dispose();
- fullScreenGeometry.dispose();
- mesh.geometry.dispose();
- material.dispose();
- sceneTarget.dispose();
- blurTargetA.dispose();
- blurTargetB.dispose();
- environmentTexture.dispose();
- renderer.dispose();
-
- if (canvas.parentNode === container) {
- container.removeChild(canvas);
- }
- };
- }, []);
+ useFaqBackground({ mountRef: mountReference });
return (
diff --git a/packages/twenty-website-new/src/sections/Faq/visuals/use-faq-background.ts b/packages/twenty-website-new/src/sections/Faq/visuals/use-faq-background.ts
new file mode 100644
index 0000000000..46545bb448
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Faq/visuals/use-faq-background.ts
@@ -0,0 +1,651 @@
+'use client';
+
+import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
+import { loadImportedGeometryFromUrl } from '@/lib/halftone';
+import { useEffect, type RefObject } from 'react';
+import * as THREE from 'three';
+import { observeElementSize } from '@/lib/dom/observe-element-size';
+import {
+ createVisualRenderLoop,
+ tryCreateSiteWebGlRenderer,
+ type VisualRenderLoop,
+ type VisualRenderLoopFrame,
+} from '@/lib/visual-runtime';
+
+const VIRTUAL_RENDER_HEIGHT = 768;
+
+const passThroughVertexShader = /* glsl */ `
+ varying vec2 vUv;
+
+ void main() {
+ vUv = uv;
+ gl_Position = vec4(position, 1.0);
+ }
+`;
+
+const blurFragmentShader = /* glsl */ `
+ precision highp float;
+
+ uniform sampler2D tInput;
+ uniform vec2 dir;
+ uniform vec2 res;
+
+ varying vec2 vUv;
+
+ void main() {
+ vec4 sum = vec4(0.0);
+ vec2 px = dir / res;
+
+ float w[5];
+ w[0] = 0.227027;
+ w[1] = 0.1945946;
+ w[2] = 0.1216216;
+ w[3] = 0.054054;
+ w[4] = 0.016216;
+
+ sum += texture2D(tInput, vUv) * w[0];
+
+ for (int i = 1; i < 5; i++) {
+ float fi = float(i) * 3.0;
+ sum += texture2D(tInput, vUv + px * fi) * w[i];
+ sum += texture2D(tInput, vUv - px * fi) * w[i];
+ }
+
+ gl_FragColor = sum;
+ }
+`;
+
+const halftoneFragmentShader = /* glsl */ `
+ precision highp float;
+
+ uniform sampler2D tScene;
+ uniform sampler2D tGlow;
+ uniform vec2 resolution;
+ uniform float numRows;
+ uniform float glowStr;
+ uniform float contrast;
+ uniform float power;
+ uniform float shading;
+ uniform float baseInk;
+ uniform float maxBar;
+ uniform float rowMerge;
+ uniform float cellRatio;
+ uniform float cutoff;
+ uniform float highlightOpen;
+ uniform float shadowGrouping;
+ uniform float shadowCrush;
+ uniform vec3 dashColor;
+ uniform float time;
+ uniform float waveAmount;
+ uniform float waveSpeed;
+ uniform float distanceScale;
+ uniform vec2 interactionUv;
+ uniform vec2 interactionVelocity;
+ uniform vec2 dragOffset;
+ uniform float hoverLightStrength;
+ uniform float hoverLightRadius;
+ uniform float hoverFlowStrength;
+ uniform float hoverFlowRadius;
+ uniform float dragFlowStrength;
+ uniform float dragFlowRadius;
+ uniform float cropToBounds;
+
+ varying vec2 vUv;
+
+ void main() {
+ // Crop to image bounds: discard fragments outside source image (image mode only)
+ if (cropToBounds > 0.5) {
+ vec4 boundsCheck = texture2D(tScene, vUv);
+ if (boundsCheck.a < 0.01) {
+ gl_FragColor = vec4(0.0);
+ return;
+ }
+ }
+
+ float baseRowH = resolution.y / (numRows * distanceScale);
+ vec2 pointerPx = interactionUv * resolution;
+ vec2 fragDelta = gl_FragCoord.xy - pointerPx;
+ float fragDist = length(fragDelta);
+ vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
+ float velocityMagnitude = length(interactionVelocity);
+ vec2 motionDir = velocityMagnitude > 0.001
+ ? interactionVelocity / velocityMagnitude
+ : vec2(0.0, 0.0);
+ float motionBias = velocityMagnitude > 0.001
+ ? dot(-radialDir, motionDir) * 0.5 + 0.5
+ : 0.5;
+
+ float hoverLightMask = 0.0;
+ if (hoverLightStrength > 0.0) {
+ float lightRadiusPx = hoverLightRadius * resolution.y;
+ hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
+ }
+
+ float hoverFlowMask = 0.0;
+ if (hoverFlowStrength > 0.0) {
+ float hoverRadiusPx = hoverFlowRadius * resolution.y;
+ hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
+ }
+
+ float dragFlowMask = 0.0;
+ if (dragFlowStrength > 0.0) {
+ float dragRadiusPx = dragFlowRadius * resolution.y;
+ dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist);
+ }
+
+ vec2 hoverDisplacement =
+ radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 +
+ motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15;
+ vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8;
+ vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement;
+
+ float densityBoost =
+ hoverFlowStrength * hoverFlowMask * 0.22 +
+ dragFlowStrength * dragFlowMask * 0.16;
+ float rowH = baseRowH / (1.0 + densityBoost);
+
+ float offsetY = effectCoord.y;
+ float row = floor(offsetY / rowH);
+ float rowFrac = offsetY / rowH - row;
+ float rowV = (row + 0.5) * rowH / resolution.y;
+ float dy = abs(rowFrac - 0.5);
+
+ float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH;
+ float effectiveX = effectCoord.x + waveOffset;
+
+ float localCellRatio = cellRatio * (
+ 1.0 +
+ hoverFlowStrength * hoverFlowMask * 0.08 +
+ dragFlowStrength * dragFlowMask * 0.1 * motionBias
+ );
+ float cellW = rowH * localCellRatio;
+ float cellIdx = floor(effectiveX / cellW);
+ float cellFrac = (effectiveX - cellIdx * cellW) / cellW;
+ float cellU = (cellIdx + 0.5) * cellW / resolution.x;
+
+ vec2 sampleUv = vec2(
+ clamp(cellU, 0.0, 1.0),
+ clamp(rowV, 0.0, 1.0)
+ );
+
+ vec4 sceneSample = texture2D(tScene, sampleUv);
+ vec4 glowCell = texture2D(tGlow, sampleUv);
+
+ float mask = smoothstep(0.02, 0.08, sceneSample.a);
+ float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114));
+ float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114));
+ float detail = lum - avgLum;
+
+ float litLum = lum + max(detail, 0.0) * shading
+ - max(-detail, 0.0) * shading * 0.55;
+ float lightLift =
+ hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34;
+ float lightFocus = hoverLightStrength * hoverLightMask * 0.12;
+ litLum = clamp(litLum + lightLift, 0.0, 1.0);
+ litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
+ litLum = pow(litLum, max(contrast - lightFocus, 0.25));
+
+ float darkness = 1.0 - litLum;
+ float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
+ groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25));
+ float groupedDarkness = 1.0 - groupedLum;
+ darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping);
+ darkness = clamp(
+ (darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001),
+ 0.0,
+ 1.0
+ );
+
+ float shadowMask = smoothstep(0.42, 0.96, darkness);
+ darkness = mix(
+ darkness,
+ mix(darkness, 1.0, shadowMask),
+ shadowCrush
+ );
+
+ float inkBase = baseInk * smoothstep(0.03, 0.24, darkness);
+ float ink = mix(inkBase, 1.0, darkness);
+ float fill = pow(ink, 1.05) * power;
+ fill = clamp(fill, 0.0, 1.0) * mask;
+
+ float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink));
+ float dynamicBarHalfY = min(
+ dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink),
+ 0.78
+ );
+ float dx2 = abs(cellFrac - 0.5);
+ float halfFill = fill * 0.5;
+ float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
+ float capRX = dynamicBarHalf * rowH;
+ float capRY = dynamicBarHalfY * rowH;
+
+ float inDash = 0.0;
+ if (dx2 <= bodyHalfW) {
+ float edgeDist = dynamicBarHalfY - dy;
+ inDash = smoothstep(-0.03, 0.03, edgeDist);
+ } else {
+ float cdx = (dx2 - bodyHalfW) * cellW;
+ float cdy = dy * rowH;
+ float ellipseDist = sqrt(
+ (cdx * cdx) / max(capRX * capRX, 0.0001) +
+ (cdy * cdy) / max(capRY * capRY, 0.0001)
+ );
+ inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist);
+ }
+
+ inDash *= step(0.001, ink) * mask;
+ inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1);
+
+ vec4 glow = texture2D(tGlow, vUv);
+ float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114));
+ float halo = glowLum * glowStr * 0.25 * (1.0 - inDash);
+ float sharp = smoothstep(0.3, 0.5, inDash + halo);
+ vec3 color = dashColor * sharp;
+
+ gl_FragColor = vec4(color, sharp);
+
+ #include
+ #include
+ }
+`;
+
+const GLB_URL = '/illustrations/common/faq/faq.glb';
+
+const LIGHT_INTENSITY = 3.1;
+const FILL_LIGHT_INTENSITY = 0.15;
+const AMBIENT_LIGHT_INTENSITY = 0.08;
+const LIGHT_ANGLE_DEGREES = 45;
+const LIGHT_HEIGHT = 2;
+
+const MATERIAL_ROUGHNESS = 0.42;
+const MATERIAL_METALNESS = 0.16;
+
+const HALFTONE_ROWS = 127;
+const HALFTONE_CONTRAST = 1.6;
+const HALFTONE_POWER = 1.2;
+const HALFTONE_SHADING = 1.6;
+const HALFTONE_BASE_INK = 0.16;
+const HALFTONE_MAX_BAR = 0.24;
+const HALFTONE_CELL_RATIO = 2.2;
+const HALFTONE_CUTOFF = 0.02;
+const HALFTONE_DASH_COLOR = '#4A38F5';
+
+const ROTATE_AXIS: 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy' = '-z';
+const ROTATE_SPEED = 0.1;
+const ROTATE_PING_PONG = false;
+const INITIAL_ROTATE_ELAPSED = 275.10000000007847;
+const INITIAL_TIME_ELAPSED = 249.21849999998116;
+const BASE_CAMERA_DISTANCE = 4;
+const MODEL_OFFSET_Y = 0.52;
+
+function createEnvironmentTexture(renderer: THREE.WebGLRenderer) {
+ const pmremGenerator = new THREE.PMREMGenerator(renderer);
+ const environmentTexture = pmremGenerator.fromScene(
+ new RoomEnvironment(),
+ 0.04,
+ ).texture;
+ pmremGenerator.dispose();
+
+ return environmentTexture;
+}
+
+function createRenderTarget(width: number, height: number) {
+ return new THREE.WebGLRenderTarget(width, height, {
+ minFilter: THREE.LinearFilter,
+ magFilter: THREE.LinearFilter,
+ format: THREE.RGBAFormat,
+ });
+}
+
+function setPrimaryLightPosition(
+ light: THREE.DirectionalLight,
+ angleDegrees: number,
+ height: number,
+) {
+ const lightAngle = (angleDegrees * Math.PI) / 180;
+ light.position.set(
+ Math.cos(lightAngle) * 5,
+ height,
+ Math.sin(lightAngle) * 5,
+ );
+}
+
+function createFaqMaterial(environmentTexture: THREE.Texture) {
+ return new THREE.MeshPhysicalMaterial({
+ color: 0xd4d0c8,
+ roughness: MATERIAL_ROUGHNESS,
+ metalness: MATERIAL_METALNESS,
+ envMap: environmentTexture,
+ envMapIntensity: 0.25,
+ clearcoat: 0,
+ clearcoatRoughness: 0.08,
+ reflectivity: 0.5,
+ transmission: 0,
+ });
+}
+
+export function useFaqBackground({
+ mountRef,
+}: {
+ mountRef: RefObject;
+}) {
+ useEffect(() => {
+ const container = mountRef.current;
+
+ if (!container) {
+ return;
+ }
+
+ let cancelled = false;
+ let renderLoop: VisualRenderLoop | null = null;
+ let rotateElapsed = INITIAL_ROTATE_ELAPSED;
+
+ 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 = tryCreateSiteWebGlRenderer({
+ antialias: false,
+ alpha: true,
+ onContextLost: () => {
+ renderLoop?.stop();
+ },
+ });
+
+ if (renderer === null) {
+ return;
+ }
+
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.setPixelRatio(1);
+ renderer.setClearColor(0x000000, 0);
+ renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
+
+ const canvas = renderer.domElement;
+ canvas.style.display = 'block';
+ canvas.style.height = '100%';
+ canvas.style.width = '100%';
+ container.appendChild(canvas);
+
+ const environmentTexture = createEnvironmentTexture(renderer);
+ const scene3d = new THREE.Scene();
+ scene3d.background = null;
+
+ const camera = new THREE.PerspectiveCamera(
+ 45,
+ getWidth() / getHeight(),
+ 0.1,
+ 100,
+ );
+ camera.position.z = BASE_CAMERA_DISTANCE;
+
+ const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHT_INTENSITY);
+ setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT);
+ scene3d.add(primaryLight);
+
+ const fillLight = new THREE.DirectionalLight(
+ 0xffffff,
+ FILL_LIGHT_INTENSITY,
+ );
+ fillLight.position.set(-3, -1, 1);
+ scene3d.add(fillLight);
+
+ const ambientLight = new THREE.AmbientLight(
+ 0xffffff,
+ AMBIENT_LIGHT_INTENSITY,
+ );
+ scene3d.add(ambientLight);
+
+ const material = createFaqMaterial(environmentTexture);
+ const mesh = new THREE.Mesh(new THREE.BufferGeometry(), material);
+ mesh.visible = false;
+ scene3d.add(mesh);
+
+ const sceneTarget = createRenderTarget(
+ getVirtualWidth(),
+ getVirtualHeight(),
+ );
+ const blurTargetA = createRenderTarget(
+ getVirtualWidth(),
+ getVirtualHeight(),
+ );
+ const blurTargetB = createRenderTarget(
+ getVirtualWidth(),
+ getVirtualHeight(),
+ );
+ const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
+ const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
+
+ const blurHorizontalMaterial = new THREE.ShaderMaterial({
+ uniforms: {
+ tInput: { value: null },
+ dir: { value: new THREE.Vector2(1, 0) },
+ res: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ },
+ vertexShader: passThroughVertexShader,
+ fragmentShader: blurFragmentShader,
+ });
+
+ const blurVerticalMaterial = new THREE.ShaderMaterial({
+ uniforms: {
+ tInput: { value: null },
+ dir: { value: new THREE.Vector2(0, 1) },
+ res: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ },
+ vertexShader: passThroughVertexShader,
+ fragmentShader: blurFragmentShader,
+ });
+
+ const halftoneMaterial = new THREE.ShaderMaterial({
+ transparent: true,
+ uniforms: {
+ tScene: { value: sceneTarget.texture },
+ tGlow: { value: blurTargetB.texture },
+ resolution: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ numRows: { value: HALFTONE_ROWS },
+ glowStr: { value: 0 },
+ contrast: { value: HALFTONE_CONTRAST },
+ power: { value: HALFTONE_POWER },
+ shading: { value: HALFTONE_SHADING },
+ baseInk: { value: HALFTONE_BASE_INK },
+ maxBar: { value: HALFTONE_MAX_BAR },
+ cellRatio: { value: HALFTONE_CELL_RATIO },
+ cutoff: { value: HALFTONE_CUTOFF },
+ dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) },
+ time: { value: 0 },
+ waveAmount: { value: 0 },
+ waveSpeed: { value: 1 },
+ distanceScale: { value: 1 },
+ interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
+ interactionVelocity: { value: new THREE.Vector2(0, 0) },
+ dragOffset: { value: new THREE.Vector2(0, 0) },
+ hoverLightStrength: { value: 0 },
+ hoverLightRadius: { value: 0.2 },
+ hoverFlowStrength: { value: 0 },
+ hoverFlowRadius: { value: 0.18 },
+ dragFlowStrength: { value: 0 },
+ dragFlowRadius: { value: 0.24 },
+ cropToBounds: { value: 0 },
+ },
+ vertexShader: passThroughVertexShader,
+ fragmentShader: halftoneFragmentShader,
+ });
+
+ const blurHorizontalScene = new THREE.Scene();
+ blurHorizontalScene.add(
+ new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
+ );
+
+ const blurVerticalScene = new THREE.Scene();
+ blurVerticalScene.add(
+ new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial),
+ );
+
+ const postScene = new THREE.Scene();
+ postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
+
+ const applySize = () => {
+ const width = getWidth();
+ const height = getHeight();
+ const virtualWidth = getVirtualWidth();
+ const virtualHeight = getVirtualHeight();
+
+ renderer.setSize(virtualWidth, virtualHeight, false);
+ camera.aspect = width / height;
+ camera.updateProjectionMatrix();
+ sceneTarget.setSize(virtualWidth, virtualHeight);
+ blurTargetA.setSize(virtualWidth, virtualHeight);
+ blurTargetB.setSize(virtualWidth, virtualHeight);
+ blurHorizontalMaterial.uniforms.res.value.set(
+ virtualWidth,
+ virtualHeight,
+ );
+ blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
+ halftoneMaterial.uniforms.resolution.value.set(
+ virtualWidth,
+ virtualHeight,
+ );
+ };
+
+ applySize();
+ const stopObservingSize = observeElementSize(container, applySize);
+
+ loadImportedGeometryFromUrl('glb', GLB_URL, 'FAQ model')
+ .then((geometry) => {
+ if (cancelled) {
+ geometry.dispose();
+ return;
+ }
+
+ mesh.geometry.dispose();
+ mesh.geometry = geometry;
+ mesh.visible = true;
+ })
+ .catch((error) => {
+ console.error(error);
+ });
+
+ const renderFrame = (
+ _timestamp: DOMHighResTimeStamp,
+ { elapsedSeconds }: VisualRenderLoopFrame,
+ ) => {
+ if (cancelled) {
+ return;
+ }
+
+ const delta = 1 / 60;
+ const elapsedTime = INITIAL_TIME_ELAPSED + elapsedSeconds;
+ halftoneMaterial.uniforms.time.value = elapsedTime;
+
+ rotateElapsed += delta;
+
+ const rotateProgress = ROTATE_PING_PONG
+ ? Math.sin(rotateElapsed * ROTATE_SPEED) * Math.PI
+ : rotateElapsed * ROTATE_SPEED;
+ const axisDirection = ROTATE_AXIS.startsWith('-') ? -1 : 1;
+ const axisProgress = rotateProgress * axisDirection;
+
+ let rotationX = 0;
+ let rotationY = 0;
+ let rotationZ = 0;
+
+ if (
+ ROTATE_AXIS === 'x' ||
+ ROTATE_AXIS === 'xy' ||
+ ROTATE_AXIS === '-x' ||
+ ROTATE_AXIS === '-xy'
+ ) {
+ rotationX += axisProgress;
+ }
+
+ if (
+ ROTATE_AXIS === 'y' ||
+ ROTATE_AXIS === 'xy' ||
+ ROTATE_AXIS === '-y' ||
+ ROTATE_AXIS === '-xy'
+ ) {
+ rotationY += axisProgress;
+ }
+
+ if (ROTATE_AXIS === 'z' || ROTATE_AXIS === '-z') {
+ rotationZ += axisProgress;
+ }
+
+ mesh.rotation.set(rotationX, rotationY, rotationZ);
+ mesh.position.y = MODEL_OFFSET_Y;
+ mesh.scale.setScalar(1);
+
+ camera.position.x += (0 - camera.position.x) * 0.12;
+ camera.position.y += (0 - camera.position.y) * 0.12;
+ camera.position.z += (BASE_CAMERA_DISTANCE - camera.position.z) * 0.12;
+ camera.lookAt(0, MODEL_OFFSET_Y * 0.2, 0);
+ setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT);
+
+ renderer.setRenderTarget(sceneTarget);
+ renderer.clear();
+ renderer.render(scene3d, camera);
+
+ blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
+ renderer.setRenderTarget(blurTargetA);
+ renderer.render(blurHorizontalScene, orthographicCamera);
+
+ blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
+ renderer.setRenderTarget(blurTargetB);
+ renderer.render(blurVerticalScene, orthographicCamera);
+
+ blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
+ renderer.setRenderTarget(blurTargetA);
+ renderer.render(blurHorizontalScene, orthographicCamera);
+
+ blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
+ renderer.setRenderTarget(blurTargetB);
+ renderer.render(blurVerticalScene, orthographicCamera);
+
+ renderer.setRenderTarget(null);
+ renderer.clear();
+ renderer.render(postScene, orthographicCamera);
+ };
+
+ renderLoop = createVisualRenderLoop({
+ renderFrame,
+ shouldRender: () => !cancelled,
+ target: container,
+ targetVisibilityOptions: { rootMargin: '100px' },
+ });
+ renderLoop.start();
+
+ return () => {
+ cancelled = true;
+ renderLoop?.dispose();
+ stopObservingSize();
+ blurHorizontalMaterial.dispose();
+ blurVerticalMaterial.dispose();
+ halftoneMaterial.dispose();
+ fullScreenGeometry.dispose();
+ mesh.geometry.dispose();
+ material.dispose();
+ sceneTarget.dispose();
+ blurTargetA.dispose();
+ blurTargetB.dispose();
+ environmentTexture.dispose();
+ renderer.dispose();
+
+ if (canvas.parentNode === container) {
+ container.removeChild(canvas);
+ }
+ };
+ }, [mountRef]);
+}
diff --git a/packages/twenty-website-new/src/sections/Feature/Feature.tsx b/packages/twenty-website-new/src/sections/Feature/Feature.tsx
new file mode 100644
index 0000000000..cd5e60b616
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Feature/Feature.tsx
@@ -0,0 +1,177 @@
+import { Container } from '@/design-system/components';
+import type { ImageType } from '@/design-system/components/Image';
+import type { MessageDescriptor } from '@lingui/core';
+import { type Page, Pages } from '@/lib/pages';
+import { theme, type Scheme } from '@/theme';
+import { styled } from '@linaria/react';
+import type { ReactNode } from 'react';
+
+import { TileContent } from './TileContent';
+import { TileVisual } from './TileVisual';
+
+export type FeatureTileType = {
+ bullets: MessageDescriptor[];
+ heading: MessageDescriptor;
+ icon: string;
+ image: ImageType;
+};
+
+// --- Root ---
+
+const StyledSection = styled.section`
+ width: 100%;
+
+ &[data-scheme='light'] {
+ background-color: var(--color-white);
+ }
+
+ &[data-scheme='muted'] {
+ background-color: var(--color-neutral);
+ }
+
+ &[data-scheme='dark'] {
+ background-color: var(--color-black);
+ }
+`;
+
+const StyledContainer = styled(Container)`
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: ${theme.spacing(6)};
+ padding-bottom: ${theme.spacing(12)};
+ padding-left: ${theme.spacing(4)};
+ padding-right: ${theme.spacing(4)};
+ padding-top: ${theme.spacing(12)};
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ gap: ${theme.spacing(10)};
+ padding-bottom: ${theme.spacing(20)};
+ padding-left: ${theme.spacing(10)};
+ padding-right: ${theme.spacing(10)};
+ padding-top: ${theme.spacing(20)};
+ }
+`;
+
+type RootProps = {
+ backgroundColor?: string;
+ children: ReactNode;
+ scheme?: Scheme;
+};
+
+function Root({ backgroundColor, children, scheme }: RootProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+// --- Intro ---
+
+const StyledIntro = styled.div`
+ display: grid;
+ grid-template-columns: 1fr;
+ row-gap: ${theme.spacing(6)};
+ text-align: start;
+
+ &[data-align='center'] {
+ justify-items: center;
+ margin-left: auto;
+ margin-right: auto;
+ text-align: center;
+ }
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ max-width: 688px;
+
+ &[data-page=${Pages.Home}] {
+ max-width: 900px;
+ }
+ }
+`;
+
+type IntroProps = {
+ align: 'center' | 'left';
+ children: ReactNode;
+ page: Page;
+};
+
+function Intro({ align, children, page }: IntroProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+// --- Tiles ---
+
+const TilesGrid = styled.div`
+ display: grid;
+ gap: ${theme.spacing(4)};
+ grid-template-columns: 1fr;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ gap: ${theme.spacing(4)};
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ }
+`;
+
+const TileCell = styled.div`
+ min-width: 0;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ &[data-index='0'] {
+ grid-column: span 12;
+ }
+ &[data-index='1'],
+ &[data-index='2'],
+ &[data-index='5'],
+ &[data-index='6'] {
+ grid-column: span 6;
+ }
+ &[data-index='3'] {
+ grid-column: span 4;
+ }
+ &[data-index='4'] {
+ grid-column: span 8;
+ }
+ }
+`;
+
+const TileRoot = styled.article`
+ background-color: ${theme.colors.primary.background[100]};
+ border: 1px solid ${theme.colors.primary.border[20]};
+ border-radius: ${theme.radius(2)};
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto;
+ height: 100%;
+ min-width: 0;
+ overflow: hidden;
+`;
+
+type TilesProps = {
+ mask: ImageType;
+ tiles: FeatureTileType[];
+};
+
+function Tiles({ mask, tiles }: TilesProps) {
+ return (
+
+ {tiles.map((tile, index) => (
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+export const Feature = { Intro, Root, Tiles };
diff --git a/packages/twenty-website-new/src/sections/Feature/components/Tile/TileContent.tsx b/packages/twenty-website-new/src/sections/Feature/TileContent.tsx
similarity index 69%
rename from packages/twenty-website-new/src/sections/Feature/components/Tile/TileContent.tsx
rename to packages/twenty-website-new/src/sections/Feature/TileContent.tsx
index f8e0bd8e58..f7714f96d7 100644
--- a/packages/twenty-website-new/src/sections/Feature/components/Tile/TileContent.tsx
+++ b/packages/twenty-website-new/src/sections/Feature/TileContent.tsx
@@ -1,10 +1,11 @@
import { styled } from '@linaria/react';
-import { Body, Heading } from '@/design-system/components';
+import { Body, Heading, HeadingPart } from '@/design-system/components';
import { INFORMATIVE_ICONS } from '@/icons';
-import type { FeatureTileType } from '@/sections/Feature/types/FeatureTile';
+import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
import { theme } from '@/theme';
-import type { MessageDescriptor } from '@lingui/core';
+
+import type { FeatureTileType } from './Feature';
const Content = styled.div`
display: grid;
@@ -53,11 +54,11 @@ const BulletItem = styled.li`
`;
type TileContentProps = {
- renderText: (descriptor: MessageDescriptor) => string;
tile: FeatureTileType;
};
-export function TileContent({ renderText, tile }: TileContentProps) {
+export function TileContent({ tile }: TileContentProps) {
+ const i18n = getServerI18n();
const Icon = INFORMATIVE_ICONS[tile.icon];
return (
@@ -69,25 +70,17 @@ export function TileContent({ renderText, tile }: TileContentProps) {
) : null}
-
+
+ {i18n._(tile.heading)}
+
{tile.bullets.map((bullet, bulletIndex) => (
-
+
+ {i18n._(bullet)}
+
))}
diff --git a/packages/twenty-website-new/src/sections/Feature/components/Tile/TileVisual.tsx b/packages/twenty-website-new/src/sections/Feature/TileVisual.tsx
similarity index 100%
rename from packages/twenty-website-new/src/sections/Feature/components/Tile/TileVisual.tsx
rename to packages/twenty-website-new/src/sections/Feature/TileVisual.tsx
diff --git a/packages/twenty-website-new/src/sections/Feature/components/Intro.tsx b/packages/twenty-website-new/src/sections/Feature/components/Intro.tsx
deleted file mode 100644
index 028a9d62e4..0000000000
--- a/packages/twenty-website-new/src/sections/Feature/components/Intro.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { type Page, Pages } from '@/lib/pages';
-import { theme } from '@/theme';
-import { styled } from '@linaria/react';
-import type { ReactNode } from 'react';
-
-const StyledIntro = styled.div`
- display: grid;
- grid-template-columns: 1fr;
- row-gap: ${theme.spacing(6)};
- text-align: start;
-
- &[data-align='center'] {
- justify-items: center;
- margin-left: auto;
- margin-right: auto;
- text-align: center;
- }
-
- @media (min-width: ${theme.breakpoints.md}px) {
- max-width: 688px;
-
- &[data-page=${Pages.Home}] {
- max-width: 900px;
- }
- }
-`;
-
-type IntroProps = {
- align: 'center' | 'left';
- children: ReactNode;
- page: Page;
-};
-
-export function Intro({ align, children, page }: IntroProps) {
- return (
-
- {children}
-
- );
-}
diff --git a/packages/twenty-website-new/src/sections/Feature/components/Root.tsx b/packages/twenty-website-new/src/sections/Feature/components/Root.tsx
deleted file mode 100644
index de39a31775..0000000000
--- a/packages/twenty-website-new/src/sections/Feature/components/Root.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Container } from '@/design-system/components';
-import { theme } from '@/theme';
-import { styled } from '@linaria/react';
-import type { ReactNode } from 'react';
-
-const StyledSection = styled.section`
- width: 100%;
-`;
-
-const StyledContainer = styled(Container)`
- display: grid;
- grid-template-columns: 1fr;
- gap: ${theme.spacing(6)};
- padding-bottom: ${theme.spacing(12)};
- padding-left: ${theme.spacing(4)};
- padding-right: ${theme.spacing(4)};
- padding-top: ${theme.spacing(12)};
-
- @media (min-width: ${theme.breakpoints.md}px) {
- gap: ${theme.spacing(10)};
- padding-bottom: ${theme.spacing(20)};
- padding-left: ${theme.spacing(10)};
- padding-right: ${theme.spacing(10)};
- padding-top: ${theme.spacing(20)};
- }
-`;
-
-type RootProps = {
- backgroundColor: string;
- children: ReactNode;
-};
-
-export function Root({ backgroundColor, children }: RootProps) {
- return (
-
- {children}
-
- );
-}
diff --git a/packages/twenty-website-new/src/sections/Feature/components/Tile/Tile.tsx b/packages/twenty-website-new/src/sections/Feature/components/Tile/Tile.tsx
deleted file mode 100644
index 377ee7c3a8..0000000000
--- a/packages/twenty-website-new/src/sections/Feature/components/Tile/Tile.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { styled } from '@linaria/react';
-
-import type { ImageType } from '@/design-system/components/Image';
-import type { FeatureTileType } from '@/sections/Feature/types/FeatureTile';
-import { theme } from '@/theme';
-import type { MessageDescriptor } from '@lingui/core';
-
-import { TileContent } from './TileContent';
-import { TileVisual } from './TileVisual';
-
-const TileRoot = styled.article`
- background-color: ${theme.colors.primary.background[100]};
- border: 1px solid ${theme.colors.primary.border[20]};
- border-radius: ${theme.radius(2)};
- display: grid;
- grid-template-columns: 1fr;
- grid-template-rows: auto auto;
- height: 100%;
- min-width: 0;
- overflow: hidden;
-`;
-
-type TileProps = {
- index: number;
- mask: ImageType;
- renderText: (descriptor: MessageDescriptor) => string;
- tile: FeatureTileType;
-};
-
-export function Tile({ index, mask, renderText, tile }: TileProps) {
- return (
-
-
-
-
- );
-}
diff --git a/packages/twenty-website-new/src/sections/Feature/components/Tiles.tsx b/packages/twenty-website-new/src/sections/Feature/components/Tiles.tsx
deleted file mode 100644
index 1693214d7e..0000000000
--- a/packages/twenty-website-new/src/sections/Feature/components/Tiles.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import type { ImageType } from '@/design-system/components/Image';
-import type { FeatureTileType } from '@/sections/Feature/types/FeatureTile';
-import { theme } from '@/theme';
-import type { MessageDescriptor } from '@lingui/core';
-import { styled } from '@linaria/react';
-import { Tile } from './Tile/Tile';
-
-const TilesGrid = styled.div`
- display: grid;
- gap: ${theme.spacing(4)};
- grid-template-columns: 1fr;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- gap: ${theme.spacing(4)};
- grid-template-columns: repeat(12, minmax(0, 1fr));
- }
-`;
-
-const TileCell = styled.div`
- min-width: 0;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- &[data-index='0'] {
- grid-column: span 12;
- }
- &[data-index='1'],
- &[data-index='2'],
- &[data-index='5'],
- &[data-index='6'] {
- grid-column: span 6;
- }
- &[data-index='3'] {
- grid-column: span 4;
- }
- &[data-index='4'] {
- grid-column: span 8;
- }
- }
-`;
-
-type TilesProps = {
- mask: ImageType;
- renderText: (descriptor: MessageDescriptor) => string;
- tiles: FeatureTileType[];
-};
-
-export function Tiles({ mask, renderText, tiles }: TilesProps) {
- return (
-
- {tiles.map((tile, index) => (
-
-
-
- ))}
-
- );
-}
diff --git a/packages/twenty-website-new/src/sections/Feature/components/index.ts b/packages/twenty-website-new/src/sections/Feature/components/index.ts
deleted file mode 100644
index b0f2a64a81..0000000000
--- a/packages/twenty-website-new/src/sections/Feature/components/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { Intro } from './Intro';
-import { Root } from './Root';
-import { Tile } from './Tile/Tile';
-import { Tiles } from './Tiles';
-
-export const Feature = { Intro, Root, Tile, Tiles };
diff --git a/packages/twenty-website-new/src/sections/Feature/index.ts b/packages/twenty-website-new/src/sections/Feature/index.ts
new file mode 100644
index 0000000000..28c791c954
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Feature/index.ts
@@ -0,0 +1 @@
+export { Feature, type FeatureTileType } from './Feature';
diff --git a/packages/twenty-website-new/src/sections/Feature/types/FeatureData.ts b/packages/twenty-website-new/src/sections/Feature/types/FeatureData.ts
deleted file mode 100644
index 4b36929cce..0000000000
--- a/packages/twenty-website-new/src/sections/Feature/types/FeatureData.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import type { MessageEyebrow } from '@/lib/i18n/message-eyebrow';
-import type { ImageType } from '@/design-system/components/Image';
-import type { FeatureTileType } from '@/sections/Feature/types/FeatureTile';
-
-export type FeatureDataType = {
- eyebrow: MessageEyebrow;
- mask: ImageType;
- tiles: FeatureTileType[];
-};
diff --git a/packages/twenty-website-new/src/sections/Feature/types/FeatureTile.ts b/packages/twenty-website-new/src/sections/Feature/types/FeatureTile.ts
deleted file mode 100644
index 7426e809fe..0000000000
--- a/packages/twenty-website-new/src/sections/Feature/types/FeatureTile.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { MessageBody } from '@/lib/i18n/message-body';
-import type { MessageHeadingSegment } from '@/lib/i18n/message-heading-segment';
-import type { ImageType } from '@/design-system/components/Image';
-
-export type FeatureTileType = {
- bullets: MessageBody[];
- heading: MessageHeadingSegment;
- icon: string;
- image: ImageType;
-};
diff --git a/packages/twenty-website-new/src/sections/Feature/types/index.ts b/packages/twenty-website-new/src/sections/Feature/types/index.ts
deleted file mode 100644
index 06ea14a9e4..0000000000
--- a/packages/twenty-website-new/src/sections/Feature/types/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export type { FeatureDataType } from './FeatureData';
-export type { FeatureTileType } from './FeatureTile';
diff --git a/packages/twenty-website-new/src/sections/Footer/components/Bottom.tsx b/packages/twenty-website-new/src/sections/Footer/components/Bottom.tsx
index 721e369942..ba523f1cb6 100644
--- a/packages/twenty-website-new/src/sections/Footer/components/Bottom.tsx
+++ b/packages/twenty-website-new/src/sections/Footer/components/Bottom.tsx
@@ -1,4 +1,5 @@
import { ArrowRightUpIcon, SOCIAL_ICONS } from '@/icons';
+import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
import type { MessageDescriptor } from '@lingui/core';
import type { FooterSocialLinkType } from '@/sections/Footer/types';
import { LocaleSwitcher } from '@/sections/Footer/components/LocaleSwitcher';
@@ -86,14 +87,14 @@ const SocialLink = styled.a`
type BottomProps = {
copyright: MessageDescriptor;
links: FooterSocialLinkType[];
- renderText: (descriptor: MessageDescriptor) => string;
};
-export function Bottom({ copyright, links, renderText }: BottomProps) {
+export function Bottom({ copyright, links }: BottomProps) {
+ const i18n = getServerI18n();
return (
- {renderText(copyright)}
+ {i18n._(copyright)}
diff --git a/packages/twenty-website-new/src/sections/Footer/components/FooterNavCta.tsx b/packages/twenty-website-new/src/sections/Footer/components/FooterNavCta.tsx
index 69c09db90e..1b4efb5b00 100644
--- a/packages/twenty-website-new/src/sections/Footer/components/FooterNavCta.tsx
+++ b/packages/twenty-website-new/src/sections/Footer/components/FooterNavCta.tsx
@@ -1,14 +1,14 @@
-import { TalkToUsButton } from '@/lib/contact-cal';
+import { TalkToUsButton } from '@/sections/ContactCal';
import { LinkButton } from '@/design-system/components';
+import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
import type { FooterCtaType } from '@/sections/Footer/types';
-import type { MessageDescriptor } from '@lingui/core';
type FooterNavCtaProps = {
cta: FooterCtaType;
- renderText: (descriptor: MessageDescriptor) => string;
};
-export function FooterNavCta({ cta, renderText }: FooterNavCtaProps) {
+export function FooterNavCta({ cta }: FooterNavCtaProps) {
+ const i18n = getServerI18n();
if (cta.kind === 'contactModal') {
return (
);
diff --git a/packages/twenty-website-new/src/sections/Footer/components/LocaleSwitcher.tsx b/packages/twenty-website-new/src/sections/Footer/components/LocaleSwitcher.tsx
index 76c1757dae..393fb8f28a 100644
--- a/packages/twenty-website-new/src/sections/Footer/components/LocaleSwitcher.tsx
+++ b/packages/twenty-website-new/src/sections/Footer/components/LocaleSwitcher.tsx
@@ -3,17 +3,24 @@
import { styled } from '@linaria/react';
import { IconCheck, IconChevronDown, IconWorld } from '@tabler/icons-react';
import Link from 'next/link';
-import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ type ChangeEvent,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { type AppLocale } from 'twenty-shared/translations';
-import { WEBSITE_LOCALE_LIST } from '@/lib/i18n/app-locale-set';
-import { localizeHref } from '@/lib/i18n/localize-href';
+import { WEBSITE_LOCALE_LIST } from '@/lib/i18n/utils/app-locale-set';
+import { localizeHref } from '@/lib/i18n/utils/localize-href';
import {
getEnglishLocaleName,
getNativeLocaleName,
-} from '@/lib/i18n/locale-display-names';
-import { useLocale } from '@/lib/i18n/use-locale';
-import { useUnlocalizedPathname } from '@/lib/i18n/use-unlocalized-pathname';
+} from '@/lib/i18n/utils/locale-display-names';
+import { useLocale } from '@/lib/i18n/hooks/use-locale';
+import { useUnlocalizedPathname } from '@/lib/i18n/hooks/use-unlocalized-pathname';
+import { LocaleSwitcherDismissEffect } from '@/sections/Footer/effect-components/LocaleSwitcherDismissEffect';
import { theme } from '@/theme';
const SEARCH_THRESHOLD = 6;
@@ -178,34 +185,9 @@ export function LocaleSwitcher() {
return ALL_LOCALES.filter((entry) => matchesQuery(entry, trimmed));
}, [query]);
- useEffect(() => {
- if (!open) return;
-
- const handlePointerDown = (event: MouseEvent) => {
- if (wrapperRef.current?.contains(event.target as Node)) return;
- setOpen(false);
- };
-
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- setOpen(false);
- triggerRef.current?.focus();
- }
- };
-
- document.addEventListener('mousedown', handlePointerDown);
- document.addEventListener('keydown', handleKeyDown);
- return () => {
- document.removeEventListener('mousedown', handlePointerDown);
- document.removeEventListener('keydown', handleKeyDown);
- };
- }, [open]);
-
- useEffect(() => {
- if (open && ALL_LOCALES.length > SEARCH_THRESHOLD) {
- searchRef.current?.focus();
- }
- }, [open]);
+ const handleDismiss = useCallback(() => {
+ setOpen(false);
+ }, []);
if (ALL_LOCALES.length < 2) return null;
@@ -230,6 +212,14 @@ export function LocaleSwitcher() {
return (
+
string;
};
-export function Nav({ groups, renderText }: NavProps) {
+export function Nav({ groups }: NavProps) {
+ const i18n = getServerI18n();
return (
}>
{groups.map((group, index) => (
@@ -150,9 +150,7 @@ export function Nav({ groups, renderText }: NavProps) {
)}
-
- {renderText(group.title)}
-
+ {i18n._(group.title)}
{group.links.map((link) => (
@@ -175,7 +173,7 @@ export function Nav({ groups, renderText }: NavProps) {
fillColor={theme.colors.secondary.background[100]}
/>
- {renderText(link.label)}
+ {i18n._(link.label)}
))}
@@ -188,7 +186,6 @@ export function Nav({ groups, renderText }: NavProps) {
cta.kind === 'link' ? `${cta.href}-${cta.kind}` : cta.kind
}
cta={cta}
- renderText={renderText}
/>
))}
diff --git a/packages/twenty-website-new/src/sections/Footer/components/__tests__/LocaleSwitcher.test.tsx b/packages/twenty-website-new/src/sections/Footer/components/__tests__/LocaleSwitcher.test.tsx
index 72203622c4..10b5407c75 100644
--- a/packages/twenty-website-new/src/sections/Footer/components/__tests__/LocaleSwitcher.test.tsx
+++ b/packages/twenty-website-new/src/sections/Footer/components/__tests__/LocaleSwitcher.test.tsx
@@ -4,13 +4,13 @@ jest.mock('next/navigation', () => ({
usePathname: jest.fn(),
}));
-jest.mock('@/lib/i18n/use-locale', () => ({
+jest.mock('@/lib/i18n/hooks/use-locale', () => ({
useLocale: jest.fn(),
}));
import { usePathname } from 'next/navigation';
-import { useLocale } from '@/lib/i18n/use-locale';
+import { useLocale } from '@/lib/i18n/hooks/use-locale';
import { LocaleSwitcher } from '@/sections/Footer/components/LocaleSwitcher';
const mockUsePathname = usePathname as jest.MockedFunction;
diff --git a/packages/twenty-website-new/src/sections/Footer/data.ts b/packages/twenty-website-new/src/sections/Footer/data.ts
index 17fe8c8be9..f6eda75bf0 100644
--- a/packages/twenty-website-new/src/sections/Footer/data.ts
+++ b/packages/twenty-website-new/src/sections/Footer/data.ts
@@ -17,7 +17,7 @@ export const FOOTER_DATA: FooterDataType = {
{ label: msg`Partners`, href: '/partners', external: false },
{
label: msg`Why Twenty`,
- href: '/resources/why-twenty',
+ href: '/why-twenty',
external: false,
},
{ label: msg`Articles`, href: '/articles', external: false },
diff --git a/packages/twenty-website-new/src/sections/Footer/effect-components/LocaleSwitcherDismissEffect.tsx b/packages/twenty-website-new/src/sections/Footer/effect-components/LocaleSwitcherDismissEffect.tsx
new file mode 100644
index 0000000000..3803845492
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Footer/effect-components/LocaleSwitcherDismissEffect.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { useEffect, type RefObject } from 'react';
+
+type LocaleSwitcherDismissEffectProps = {
+ open: boolean;
+ onDismiss: () => void;
+ wrapperRef: RefObject;
+ triggerRef: RefObject;
+ searchRef: RefObject;
+ showSearch: boolean;
+};
+
+export function LocaleSwitcherDismissEffect({
+ open,
+ onDismiss,
+ wrapperRef,
+ triggerRef,
+ searchRef,
+ showSearch,
+}: LocaleSwitcherDismissEffectProps) {
+ useEffect(() => {
+ if (!open) return;
+
+ const handlePointerDown = (event: MouseEvent) => {
+ if (wrapperRef.current?.contains(event.target as Node)) return;
+ onDismiss();
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onDismiss();
+ triggerRef.current?.focus();
+ }
+ };
+
+ document.addEventListener('mousedown', handlePointerDown);
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('mousedown', handlePointerDown);
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [open, onDismiss, wrapperRef, triggerRef]);
+
+ useEffect(() => {
+ if (open && showSearch) {
+ searchRef.current?.focus();
+ }
+ }, [open, showSearch, searchRef]);
+
+ return null;
+}
diff --git a/packages/twenty-website-new/src/sections/Footer/index.ts b/packages/twenty-website-new/src/sections/Footer/index.ts
new file mode 100644
index 0000000000..b85f2a7850
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Footer/index.ts
@@ -0,0 +1,10 @@
+export { Footer } from './components';
+export { FOOTER_DATA } from './data';
+export type {
+ FooterBottomType,
+ FooterCtaType,
+ FooterDataType,
+ FooterNavGroupType,
+ FooterNavLinkType,
+ FooterSocialLinkType,
+} from './types';
diff --git a/packages/twenty-website-new/src/sections/Footer/types/FooterData.ts b/packages/twenty-website-new/src/sections/Footer/types/FooterData.ts
deleted file mode 100644
index ad1bb7f698..0000000000
--- a/packages/twenty-website-new/src/sections/Footer/types/FooterData.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import type { FooterBottomType } from './FooterBottom';
-import type { FooterNavGroupType } from './FooterNavGroup';
-import type { FooterSocialLinkType } from './FooterSocialLink';
-
-export type FooterDataType = {
- bottom: FooterBottomType;
- navGroups: FooterNavGroupType[];
- socialLinks: FooterSocialLinkType[];
-};
diff --git a/packages/twenty-website-new/src/sections/Footer/types/FooterBottom.ts b/packages/twenty-website-new/src/sections/Footer/types/footer-bottom.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Footer/types/FooterBottom.ts
rename to packages/twenty-website-new/src/sections/Footer/types/footer-bottom.ts
diff --git a/packages/twenty-website-new/src/sections/Footer/types/FooterCta.ts b/packages/twenty-website-new/src/sections/Footer/types/footer-cta.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Footer/types/FooterCta.ts
rename to packages/twenty-website-new/src/sections/Footer/types/footer-cta.ts
diff --git a/packages/twenty-website-new/src/sections/Footer/types/footer-data.ts b/packages/twenty-website-new/src/sections/Footer/types/footer-data.ts
new file mode 100644
index 0000000000..035272a6ff
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Footer/types/footer-data.ts
@@ -0,0 +1,9 @@
+import type { FooterBottomType } from './footer-bottom';
+import type { FooterNavGroupType } from './footer-nav-group';
+import type { FooterSocialLinkType } from './footer-social-link';
+
+export type FooterDataType = {
+ bottom: FooterBottomType;
+ navGroups: FooterNavGroupType[];
+ socialLinks: FooterSocialLinkType[];
+};
diff --git a/packages/twenty-website-new/src/sections/Footer/types/FooterNavGroup.ts b/packages/twenty-website-new/src/sections/Footer/types/footer-nav-group.ts
similarity index 63%
rename from packages/twenty-website-new/src/sections/Footer/types/FooterNavGroup.ts
rename to packages/twenty-website-new/src/sections/Footer/types/footer-nav-group.ts
index 8f79d6f0cb..e22c7714cd 100644
--- a/packages/twenty-website-new/src/sections/Footer/types/FooterNavGroup.ts
+++ b/packages/twenty-website-new/src/sections/Footer/types/footer-nav-group.ts
@@ -1,6 +1,6 @@
import type { MessageDescriptor } from '@lingui/core';
-import type { FooterCtaType } from './FooterCta';
-import type { FooterNavLinkType } from './FooterNavLink';
+import type { FooterCtaType } from './footer-cta';
+import type { FooterNavLinkType } from './footer-nav-link';
export type FooterNavGroupType = {
ctas: FooterCtaType[];
diff --git a/packages/twenty-website-new/src/sections/Footer/types/FooterNavLink.ts b/packages/twenty-website-new/src/sections/Footer/types/footer-nav-link.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Footer/types/FooterNavLink.ts
rename to packages/twenty-website-new/src/sections/Footer/types/footer-nav-link.ts
diff --git a/packages/twenty-website-new/src/sections/Footer/types/FooterSocialLink.ts b/packages/twenty-website-new/src/sections/Footer/types/footer-social-link.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Footer/types/FooterSocialLink.ts
rename to packages/twenty-website-new/src/sections/Footer/types/footer-social-link.ts
diff --git a/packages/twenty-website-new/src/sections/Footer/types/index.ts b/packages/twenty-website-new/src/sections/Footer/types/index.ts
index 2efff9e4b6..d1a55b2cd7 100644
--- a/packages/twenty-website-new/src/sections/Footer/types/index.ts
+++ b/packages/twenty-website-new/src/sections/Footer/types/index.ts
@@ -1,6 +1,6 @@
-export type { FooterBottomType } from './FooterBottom';
-export type { FooterCtaType } from './FooterCta';
-export type { FooterDataType } from './FooterData';
-export type { FooterNavGroupType } from './FooterNavGroup';
-export type { FooterNavLinkType } from './FooterNavLink';
-export type { FooterSocialLinkType } from './FooterSocialLink';
+export type { FooterBottomType } from './footer-bottom';
+export type { FooterCtaType } from './footer-cta';
+export type { FooterDataType } from './footer-data';
+export type { FooterNavGroupType } from './footer-nav-group';
+export type { FooterNavLinkType } from './footer-nav-link';
+export type { FooterSocialLinkType } from './footer-social-link';
diff --git a/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx b/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx
index dfcbe1aaef..92d12dc19c 100644
--- a/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx
+++ b/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx
@@ -1,367 +1,8 @@
'use client';
-import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
-import { loadImportedGeometryFromUrl } from '@/lib/halftone';
import { styled } from '@linaria/react';
-import { useEffect, useRef } from 'react';
-import * as THREE from 'three';
-import { observeElementSize } from '@/lib/dom/observe-element-size';
-import {
- createVisualRenderLoop,
- tryCreateSiteWebGlRenderer,
- type VisualRenderLoop,
- type VisualRenderLoopFrame,
-} from '@/lib/visual-runtime';
-
-const VIRTUAL_RENDER_HEIGHT = 768;
-
-const passThroughVertexShader = /* glsl */ `
- varying vec2 vUv;
-
- void main() {
- vUv = uv;
- gl_Position = vec4(position, 1.0);
- }
-`;
-
-const blurFragmentShader = /* glsl */ `
- precision highp float;
-
- uniform sampler2D tInput;
- uniform vec2 dir;
- uniform vec2 res;
-
- varying vec2 vUv;
-
- void main() {
- vec4 sum = vec4(0.0);
- vec2 px = dir / res;
-
- float w[5];
- w[0] = 0.227027;
- w[1] = 0.1945946;
- w[2] = 0.1216216;
- w[3] = 0.054054;
- w[4] = 0.016216;
-
- sum += texture2D(tInput, vUv) * w[0];
-
- for (int i = 1; i < 5; i++) {
- float fi = float(i) * 3.0;
- sum += texture2D(tInput, vUv + px * fi) * w[i];
- sum += texture2D(tInput, vUv - px * fi) * w[i];
- }
-
- gl_FragColor = sum;
- }
-`;
-
-const halftoneFragmentShader = /* glsl */ `
- precision highp float;
-
- uniform sampler2D tScene;
- uniform sampler2D tGlow;
- uniform vec2 resolution;
- uniform float numRows;
- uniform float glowStr;
- uniform float contrast;
- uniform float power;
- uniform float shading;
- uniform float baseInk;
- uniform float maxBar;
- uniform float rowMerge;
- uniform float cellRatio;
- uniform float cutoff;
- uniform float highlightOpen;
- uniform float shadowGrouping;
- uniform float shadowCrush;
- uniform vec3 dashColor;
- uniform float time;
- uniform float waveAmount;
- uniform float waveSpeed;
- uniform float distanceScale;
- uniform vec2 interactionUv;
- uniform vec2 interactionVelocity;
- uniform vec2 dragOffset;
- uniform float hoverLightStrength;
- uniform float hoverLightRadius;
- uniform float hoverFlowStrength;
- uniform float hoverFlowRadius;
- uniform float dragFlowStrength;
- uniform float dragFlowRadius;
- uniform float cropToBounds;
-
- varying vec2 vUv;
-
- void main() {
- // Crop to image bounds: discard fragments outside source image (image mode only)
- if (cropToBounds > 0.5) {
- vec4 boundsCheck = texture2D(tScene, vUv);
- if (boundsCheck.a < 0.01) {
- gl_FragColor = vec4(0.0);
- return;
- }
- }
-
- float baseRowH = resolution.y / (numRows * distanceScale);
- vec2 pointerPx = interactionUv * resolution;
- vec2 fragDelta = gl_FragCoord.xy - pointerPx;
- float fragDist = length(fragDelta);
- vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
- float velocityMagnitude = length(interactionVelocity);
- vec2 motionDir = velocityMagnitude > 0.001
- ? interactionVelocity / velocityMagnitude
- : vec2(0.0, 0.0);
- float motionBias = velocityMagnitude > 0.001
- ? dot(-radialDir, motionDir) * 0.5 + 0.5
- : 0.5;
-
- float hoverLightMask = 0.0;
- if (hoverLightStrength > 0.0) {
- float lightRadiusPx = hoverLightRadius * resolution.y;
- hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
- }
-
- float hoverFlowMask = 0.0;
- if (hoverFlowStrength > 0.0) {
- float hoverRadiusPx = hoverFlowRadius * resolution.y;
- hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
- }
-
- float dragFlowMask = 0.0;
- if (dragFlowStrength > 0.0) {
- float dragRadiusPx = dragFlowRadius * resolution.y;
- dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist);
- }
-
- vec2 hoverDisplacement =
- radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 +
- motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15;
- vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8;
- vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement;
-
- float densityBoost =
- hoverFlowStrength * hoverFlowMask * 0.22 +
- dragFlowStrength * dragFlowMask * 0.16;
- float rowH = baseRowH / (1.0 + densityBoost);
-
- float offsetY = effectCoord.y;
- float row = floor(offsetY / rowH);
- float rowFrac = offsetY / rowH - row;
- float rowV = (row + 0.5) * rowH / resolution.y;
- float dy = abs(rowFrac - 0.5);
-
- float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH;
- float effectiveX = effectCoord.x + waveOffset;
-
- float localCellRatio = cellRatio * (
- 1.0 +
- hoverFlowStrength * hoverFlowMask * 0.08 +
- dragFlowStrength * dragFlowMask * 0.1 * motionBias
- );
- float cellW = rowH * localCellRatio;
- float cellIdx = floor(effectiveX / cellW);
- float cellFrac = (effectiveX - cellIdx * cellW) / cellW;
- float cellU = (cellIdx + 0.5) * cellW / resolution.x;
-
- vec2 sampleUv = vec2(
- clamp(cellU, 0.0, 1.0),
- clamp(rowV, 0.0, 1.0)
- );
-
- vec4 sceneSample = texture2D(tScene, sampleUv);
- vec4 glowCell = texture2D(tGlow, sampleUv);
-
- float mask = smoothstep(0.02, 0.08, sceneSample.a);
- float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114));
- float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114));
- float detail = lum - avgLum;
-
- float litLum = lum + max(detail, 0.0) * shading
- - max(-detail, 0.0) * shading * 0.55;
- float lightLift =
- hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34;
- float lightFocus = hoverLightStrength * hoverLightMask * 0.12;
- litLum = clamp(litLum + lightLift, 0.0, 1.0);
- litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
- litLum = pow(litLum, max(contrast - lightFocus, 0.25));
-
- float darkness = 1.0 - litLum;
- float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
- groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25));
- float groupedDarkness = 1.0 - groupedLum;
- darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping);
- darkness = clamp(
- (darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001),
- 0.0,
- 1.0
- );
-
- float shadowMask = smoothstep(0.42, 0.96, darkness);
- darkness = mix(
- darkness,
- mix(darkness, 1.0, shadowMask),
- shadowCrush
- );
-
- float inkBase = baseInk * smoothstep(0.03, 0.24, darkness);
- float ink = mix(inkBase, 1.0, darkness);
- float fill = pow(ink, 1.05) * power;
- fill = clamp(fill, 0.0, 1.0) * mask;
-
- float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink));
- float dynamicBarHalfY = min(
- dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink),
- 0.78
- );
- float dx2 = abs(cellFrac - 0.5);
- float halfFill = fill * 0.5;
- float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
- float capRX = dynamicBarHalf * rowH;
- float capRY = dynamicBarHalfY * rowH;
-
- float inDash = 0.0;
- if (dx2 <= bodyHalfW) {
- float edgeDist = dynamicBarHalfY - dy;
- inDash = smoothstep(-0.03, 0.03, edgeDist);
- } else {
- float cdx = (dx2 - bodyHalfW) * cellW;
- float cdy = dy * rowH;
- float ellipseDist = sqrt(
- (cdx * cdx) / max(capRX * capRX, 0.0001) +
- (cdy * cdy) / max(capRY * capRY, 0.0001)
- );
- inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist);
- }
-
- inDash *= step(0.001, ink) * mask;
- inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1);
-
- vec4 glow = texture2D(tGlow, vUv);
- float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114));
- float halo = glowLum * glowStr * 0.25 * (1.0 - inDash);
- float sharp = smoothstep(0.3, 0.5, inDash + halo);
- vec3 color = dashColor * sharp;
-
- gl_FragColor = vec4(color, sharp);
-
- #include
- #include
- }
-`;
-
-const GLB_URL = '/illustrations/common/footer/footer.glb';
-
-const LIGHT_INTENSITY = 4;
-const FILL_LIGHT_INTENSITY = 1.33;
-const AMBIENT_LIGHT_INTENSITY = 0.28;
-const LIGHT_ANGLE_DEGREES = 167;
-const LIGHT_HEIGHT = 2.1;
-
-const MATERIAL_ROUGHNESS = 0.42;
-const MATERIAL_METALNESS = 0.16;
-
-const HALFTONE_ROWS = 124;
-const HALFTONE_CONTRAST = 2.7;
-const HALFTONE_POWER = 1;
-const HALFTONE_SHADING = 2.9;
-const HALFTONE_BASE_INK = 0.19;
-const HALFTONE_MAX_BAR = 0.25;
-const HALFTONE_CELL_RATIO = 2.2;
-const HALFTONE_CUTOFF = 0.02;
-const HALFTONE_DASH_COLOR = '#2a2a2a';
-
-const DRAG_SENSITIVITY = 0.008;
-const DRAG_FRICTION = 0.08;
-const DRAG_MOMENTUM = true;
-const BREATHE_AMOUNT = 0.04;
-const BREATHE_SPEED = 0.8;
-
-const INITIAL_ROTATION_X = -0.5858908325312259;
-const INITIAL_ROTATION_Y = 0.45733435420243096;
-const INITIAL_ROTATION_Z = 0;
-const INITIAL_TARGET_ROTATION_X = -0.5858908325312265;
-const INITIAL_TARGET_ROTATION_Y = 0.4573343542024313;
-const INITIAL_TIME_ELAPSED = 287.372499999977;
-const BASE_CAMERA_DISTANCE = 4;
-
-function createEnvironmentTexture(renderer: THREE.WebGLRenderer) {
- const pmremGenerator = new THREE.PMREMGenerator(renderer);
- const environmentTexture = pmremGenerator.fromScene(
- new RoomEnvironment(),
- 0.04,
- ).texture;
- pmremGenerator.dispose();
-
- return environmentTexture;
-}
-
-type FooterInteractionState = {
- dragging: boolean;
- mouseX: number;
- mouseY: number;
- pointerX: number;
- pointerY: number;
- rotationX: number;
- rotationY: number;
- rotationZ: number;
- targetRotationX: number;
- targetRotationY: number;
- velocityX: number;
- velocityY: number;
-};
-
-function createInteractionState(): FooterInteractionState {
- return {
- dragging: false,
- mouseX: 0.5,
- mouseY: 0.5,
- pointerX: 0,
- pointerY: 0,
- rotationX: INITIAL_ROTATION_X,
- rotationY: INITIAL_ROTATION_Y,
- rotationZ: INITIAL_ROTATION_Z,
- targetRotationX: INITIAL_TARGET_ROTATION_X,
- targetRotationY: INITIAL_TARGET_ROTATION_Y,
- velocityX: 0,
- velocityY: 0,
- };
-}
-
-function createRenderTarget(width: number, height: number) {
- return new THREE.WebGLRenderTarget(width, height, {
- minFilter: THREE.LinearFilter,
- magFilter: THREE.LinearFilter,
- format: THREE.RGBAFormat,
- });
-}
-
-function setPrimaryLightPosition(
- light: THREE.DirectionalLight,
- angleDegrees: number,
- height: number,
-) {
- const lightAngle = (angleDegrees * Math.PI) / 180;
- light.position.set(
- Math.cos(lightAngle) * 5,
- height,
- Math.sin(lightAngle) * 5,
- );
-}
-
-function createFooterMaterial(environmentTexture: THREE.Texture) {
- return new THREE.MeshPhysicalMaterial({
- color: 0xd4d0c8,
- roughness: MATERIAL_ROUGHNESS,
- metalness: MATERIAL_METALNESS,
- envMap: environmentTexture,
- envMapIntensity: 0.25,
- clearcoat: 0,
- clearcoatRoughness: 0.08,
- reflectivity: 0.5,
- transmission: 0,
- });
-}
+import { useRef } from 'react';
+import { useFooterBackground } from './use-footer-background';
const FooterBackgroundCanvasRoot = styled.div`
position: absolute;
@@ -388,398 +29,7 @@ const FooterVisualCanvasMount = styled.div`
export function FooterBackground() {
const mountReference = useRef(null);
- useEffect(() => {
- const container = mountReference.current;
-
- if (!container) {
- return;
- }
-
- let cancelled = false;
- let renderLoop: VisualRenderLoop | null = null;
-
- 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 = tryCreateSiteWebGlRenderer({
- antialias: false,
- alpha: true,
- onContextLost: () => {
- renderLoop?.stop();
- },
- });
-
- if (renderer === null) {
- return;
- }
-
- renderer.outputColorSpace = THREE.SRGBColorSpace;
- renderer.setPixelRatio(1);
- renderer.setClearColor(0x000000, 0);
- renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
-
- const canvas = renderer.domElement;
- canvas.style.cursor = 'grab';
- canvas.style.display = 'block';
- canvas.style.height = '100%';
- canvas.style.touchAction = 'none';
- canvas.style.width = '100%';
- container.appendChild(canvas);
-
- const environmentTexture = createEnvironmentTexture(renderer);
- const scene3d = new THREE.Scene();
- scene3d.background = null;
-
- const camera = new THREE.PerspectiveCamera(
- 45,
- getWidth() / getHeight(),
- 0.1,
- 100,
- );
- camera.position.z = BASE_CAMERA_DISTANCE;
-
- const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHT_INTENSITY);
- setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT);
- scene3d.add(primaryLight);
-
- const fillLight = new THREE.DirectionalLight(
- 0xffffff,
- FILL_LIGHT_INTENSITY,
- );
- fillLight.position.set(-3, -1, 1);
- scene3d.add(fillLight);
-
- const ambientLight = new THREE.AmbientLight(
- 0xffffff,
- AMBIENT_LIGHT_INTENSITY,
- );
- scene3d.add(ambientLight);
-
- const material = createFooterMaterial(environmentTexture);
- const mesh = new THREE.Mesh(new THREE.BufferGeometry(), material);
- mesh.visible = false;
- scene3d.add(mesh);
-
- const sceneTarget = createRenderTarget(
- getVirtualWidth(),
- getVirtualHeight(),
- );
- const blurTargetA = createRenderTarget(
- getVirtualWidth(),
- getVirtualHeight(),
- );
- const blurTargetB = createRenderTarget(
- getVirtualWidth(),
- getVirtualHeight(),
- );
- const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
- const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
-
- const blurHorizontalMaterial = new THREE.ShaderMaterial({
- uniforms: {
- tInput: { value: null },
- dir: { value: new THREE.Vector2(1, 0) },
- res: {
- value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
- },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: blurFragmentShader,
- });
-
- const blurVerticalMaterial = new THREE.ShaderMaterial({
- uniforms: {
- tInput: { value: null },
- dir: { value: new THREE.Vector2(0, 1) },
- res: {
- value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
- },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: blurFragmentShader,
- });
-
- const halftoneMaterial = new THREE.ShaderMaterial({
- transparent: true,
- uniforms: {
- tScene: { value: sceneTarget.texture },
- tGlow: { value: blurTargetB.texture },
- resolution: {
- value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
- },
- numRows: { value: HALFTONE_ROWS },
- glowStr: { value: 0 },
- contrast: { value: HALFTONE_CONTRAST },
- power: { value: HALFTONE_POWER },
- shading: { value: HALFTONE_SHADING },
- baseInk: { value: HALFTONE_BASE_INK },
- maxBar: { value: HALFTONE_MAX_BAR },
- cellRatio: { value: HALFTONE_CELL_RATIO },
- cutoff: { value: HALFTONE_CUTOFF },
- dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) },
- time: { value: 0 },
- waveAmount: { value: 0 },
- waveSpeed: { value: 1 },
- distanceScale: { value: 1 },
- interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
- interactionVelocity: { value: new THREE.Vector2(0, 0) },
- dragOffset: { value: new THREE.Vector2(0, 0) },
- hoverLightStrength: { value: 0 },
- hoverLightRadius: { value: 0.2 },
- hoverFlowStrength: { value: 0 },
- hoverFlowRadius: { value: 0.18 },
- dragFlowStrength: { value: 0 },
- dragFlowRadius: { value: 0.24 },
- cropToBounds: { value: 0 },
- },
- vertexShader: passThroughVertexShader,
- fragmentShader: halftoneFragmentShader,
- });
-
- const blurHorizontalScene = new THREE.Scene();
- blurHorizontalScene.add(
- new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
- );
-
- const blurVerticalScene = new THREE.Scene();
- blurVerticalScene.add(
- new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial),
- );
-
- const postScene = new THREE.Scene();
- postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
-
- const interaction = createInteractionState();
-
- const syncSize = () => {
- const width = getWidth();
- const height = getHeight();
- const virtualWidth = getVirtualWidth();
- const virtualHeight = getVirtualHeight();
-
- renderer.setSize(virtualWidth, virtualHeight, false);
- camera.aspect = width / height;
- camera.updateProjectionMatrix();
- sceneTarget.setSize(virtualWidth, virtualHeight);
- blurTargetA.setSize(virtualWidth, virtualHeight);
- blurTargetB.setSize(virtualWidth, virtualHeight);
- blurHorizontalMaterial.uniforms.res.value.set(
- virtualWidth,
- virtualHeight,
- );
- blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
- halftoneMaterial.uniforms.resolution.value.set(
- virtualWidth,
- virtualHeight,
- );
- };
-
- const stopObservingSize = observeElementSize(container, syncSize);
-
- const updatePointerPosition = (event: PointerEvent) => {
- const rect = canvas.getBoundingClientRect();
- const width = Math.max(rect.width, 1);
- const height = Math.max(rect.height, 1);
-
- interaction.mouseX = THREE.MathUtils.clamp(
- (event.clientX - rect.left) / width,
- 0,
- 1,
- );
- interaction.mouseY = THREE.MathUtils.clamp(
- (event.clientY - rect.top) / height,
- 0,
- 1,
- );
- };
-
- const handlePointerDown = (event: PointerEvent) => {
- updatePointerPosition(event);
- interaction.dragging = false;
- interaction.pointerX = event.clientX;
- interaction.pointerY = event.clientY;
- interaction.velocityX = 0;
- interaction.velocityY = 0;
- canvas.style.cursor = 'default';
- };
-
- const handlePointerMove = (event: PointerEvent) => {
- updatePointerPosition(event);
- };
-
- const handleWindowPointerMove = (event: PointerEvent) => {
- updatePointerPosition(event);
-
- if (!interaction.dragging) {
- return;
- }
-
- const deltaX = (event.clientX - interaction.pointerX) * DRAG_SENSITIVITY;
- const deltaY = (event.clientY - interaction.pointerY) * DRAG_SENSITIVITY;
- interaction.velocityX = deltaY;
- interaction.velocityY = deltaX;
- interaction.targetRotationY += deltaX;
- interaction.targetRotationX += deltaY;
- interaction.pointerX = event.clientX;
- interaction.pointerY = event.clientY;
- };
-
- const handlePointerLeave = () => {
- if (interaction.dragging) {
- return;
- }
-
- interaction.mouseX = 0.5;
- interaction.mouseY = 0.5;
- };
-
- const handlePointerUp = () => {
- interaction.dragging = false;
- canvas.style.cursor = 'grab';
- };
-
- const handleWindowBlur = () => {
- handlePointerUp();
- handlePointerLeave();
- };
-
- canvas.addEventListener('pointermove', handlePointerMove);
- canvas.addEventListener('pointerleave', handlePointerLeave);
- canvas.addEventListener('pointerdown', handlePointerDown);
- window.addEventListener('pointerup', handlePointerUp);
- window.addEventListener('pointermove', handleWindowPointerMove);
- window.addEventListener('blur', handleWindowBlur);
-
- loadImportedGeometryFromUrl('glb', GLB_URL, 'Footer model', {
- scaleTarget: 2,
- })
- .then((geometry) => {
- if (cancelled) {
- geometry.dispose();
- return;
- }
-
- mesh.geometry.dispose();
- mesh.geometry = geometry;
- mesh.visible = true;
- })
- .catch((error) => {
- console.error(error);
- });
-
- const renderFrame = (
- _timestamp: DOMHighResTimeStamp,
- { elapsedSeconds }: VisualRenderLoopFrame,
- ) => {
- if (cancelled) {
- return;
- }
-
- const elapsedTime = INITIAL_TIME_ELAPSED + elapsedSeconds;
- halftoneMaterial.uniforms.time.value = elapsedTime;
- halftoneMaterial.uniforms.interactionUv.value.set(
- interaction.mouseX,
- 1 - interaction.mouseY,
- );
-
- if (!interaction.dragging && DRAG_MOMENTUM) {
- interaction.targetRotationX += interaction.velocityX;
- interaction.targetRotationY += interaction.velocityY;
- interaction.velocityX *= 1 - DRAG_FRICTION;
- interaction.velocityY *= 1 - DRAG_FRICTION;
- }
-
- interaction.rotationX +=
- (interaction.targetRotationX - interaction.rotationX) * DRAG_FRICTION;
- interaction.rotationY +=
- (interaction.targetRotationY - interaction.rotationY) * DRAG_FRICTION;
- interaction.rotationZ += (0 - interaction.rotationZ) * 0.12;
-
- const meshScale =
- 1 + Math.sin(elapsedTime * BREATHE_SPEED) * BREATHE_AMOUNT;
-
- mesh.rotation.set(
- interaction.rotationX,
- interaction.rotationY,
- interaction.rotationZ,
- );
- mesh.position.y = 0;
- mesh.scale.setScalar(meshScale);
-
- camera.position.x += (0 - camera.position.x) * 0.12;
- camera.position.y += (0 - camera.position.y) * 0.12;
- camera.position.z += (BASE_CAMERA_DISTANCE - camera.position.z) * 0.12;
- camera.lookAt(0, 0, 0);
- setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT);
-
- renderer.setRenderTarget(sceneTarget);
- renderer.clear();
- renderer.render(scene3d, camera);
-
- blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
- renderer.setRenderTarget(blurTargetA);
- renderer.render(blurHorizontalScene, orthographicCamera);
-
- blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
- renderer.setRenderTarget(blurTargetB);
- renderer.render(blurVerticalScene, orthographicCamera);
-
- blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
- renderer.setRenderTarget(blurTargetA);
- renderer.render(blurHorizontalScene, orthographicCamera);
-
- blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
- renderer.setRenderTarget(blurTargetB);
- renderer.render(blurVerticalScene, orthographicCamera);
-
- renderer.setRenderTarget(null);
- renderer.clear();
- renderer.render(postScene, orthographicCamera);
- };
-
- renderLoop = createVisualRenderLoop({
- renderFrame,
- shouldRender: () => !cancelled,
- target: container,
- targetVisibilityOptions: { rootMargin: '100px' },
- });
- renderLoop.start();
-
- return () => {
- cancelled = true;
- renderLoop?.dispose();
- stopObservingSize();
- canvas.removeEventListener('pointermove', handlePointerMove);
- canvas.removeEventListener('pointerleave', handlePointerLeave);
- canvas.removeEventListener('pointerdown', handlePointerDown);
- window.removeEventListener('pointerup', handlePointerUp);
- window.removeEventListener('pointermove', handleWindowPointerMove);
- window.removeEventListener('blur', handleWindowBlur);
- blurHorizontalMaterial.dispose();
- blurVerticalMaterial.dispose();
- halftoneMaterial.dispose();
- fullScreenGeometry.dispose();
- mesh.geometry.dispose();
- material.dispose();
- sceneTarget.dispose();
- blurTargetA.dispose();
- blurTargetB.dispose();
- environmentTexture.dispose();
- renderer.dispose();
-
- if (canvas.parentNode === container) {
- container.removeChild(canvas);
- }
- };
- }, []);
+ useFooterBackground({ mountRef: mountReference });
return (
diff --git a/packages/twenty-website-new/src/sections/Footer/visuals/use-footer-background.ts b/packages/twenty-website-new/src/sections/Footer/visuals/use-footer-background.ts
new file mode 100644
index 0000000000..544217f6bb
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Footer/visuals/use-footer-background.ts
@@ -0,0 +1,762 @@
+'use client';
+
+import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
+import { loadImportedGeometryFromUrl } from '@/lib/halftone';
+import { useEffect, type RefObject } from 'react';
+import * as THREE from 'three';
+import { observeElementSize } from '@/lib/dom/observe-element-size';
+import {
+ createVisualRenderLoop,
+ tryCreateSiteWebGlRenderer,
+ type VisualRenderLoop,
+ type VisualRenderLoopFrame,
+} from '@/lib/visual-runtime';
+
+const VIRTUAL_RENDER_HEIGHT = 768;
+
+const passThroughVertexShader = /* glsl */ `
+ varying vec2 vUv;
+
+ void main() {
+ vUv = uv;
+ gl_Position = vec4(position, 1.0);
+ }
+`;
+
+const blurFragmentShader = /* glsl */ `
+ precision highp float;
+
+ uniform sampler2D tInput;
+ uniform vec2 dir;
+ uniform vec2 res;
+
+ varying vec2 vUv;
+
+ void main() {
+ vec4 sum = vec4(0.0);
+ vec2 px = dir / res;
+
+ float w[5];
+ w[0] = 0.227027;
+ w[1] = 0.1945946;
+ w[2] = 0.1216216;
+ w[3] = 0.054054;
+ w[4] = 0.016216;
+
+ sum += texture2D(tInput, vUv) * w[0];
+
+ for (int i = 1; i < 5; i++) {
+ float fi = float(i) * 3.0;
+ sum += texture2D(tInput, vUv + px * fi) * w[i];
+ sum += texture2D(tInput, vUv - px * fi) * w[i];
+ }
+
+ gl_FragColor = sum;
+ }
+`;
+
+const halftoneFragmentShader = /* glsl */ `
+ precision highp float;
+
+ uniform sampler2D tScene;
+ uniform sampler2D tGlow;
+ uniform vec2 resolution;
+ uniform float numRows;
+ uniform float glowStr;
+ uniform float contrast;
+ uniform float power;
+ uniform float shading;
+ uniform float baseInk;
+ uniform float maxBar;
+ uniform float rowMerge;
+ uniform float cellRatio;
+ uniform float cutoff;
+ uniform float highlightOpen;
+ uniform float shadowGrouping;
+ uniform float shadowCrush;
+ uniform vec3 dashColor;
+ uniform float time;
+ uniform float waveAmount;
+ uniform float waveSpeed;
+ uniform float distanceScale;
+ uniform vec2 interactionUv;
+ uniform vec2 interactionVelocity;
+ uniform vec2 dragOffset;
+ uniform float hoverLightStrength;
+ uniform float hoverLightRadius;
+ uniform float hoverFlowStrength;
+ uniform float hoverFlowRadius;
+ uniform float dragFlowStrength;
+ uniform float dragFlowRadius;
+ uniform float cropToBounds;
+
+ varying vec2 vUv;
+
+ void main() {
+ // Crop to image bounds: discard fragments outside source image (image mode only)
+ if (cropToBounds > 0.5) {
+ vec4 boundsCheck = texture2D(tScene, vUv);
+ if (boundsCheck.a < 0.01) {
+ gl_FragColor = vec4(0.0);
+ return;
+ }
+ }
+
+ float baseRowH = resolution.y / (numRows * distanceScale);
+ vec2 pointerPx = interactionUv * resolution;
+ vec2 fragDelta = gl_FragCoord.xy - pointerPx;
+ float fragDist = length(fragDelta);
+ vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
+ float velocityMagnitude = length(interactionVelocity);
+ vec2 motionDir = velocityMagnitude > 0.001
+ ? interactionVelocity / velocityMagnitude
+ : vec2(0.0, 0.0);
+ float motionBias = velocityMagnitude > 0.001
+ ? dot(-radialDir, motionDir) * 0.5 + 0.5
+ : 0.5;
+
+ float hoverLightMask = 0.0;
+ if (hoverLightStrength > 0.0) {
+ float lightRadiusPx = hoverLightRadius * resolution.y;
+ hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
+ }
+
+ float hoverFlowMask = 0.0;
+ if (hoverFlowStrength > 0.0) {
+ float hoverRadiusPx = hoverFlowRadius * resolution.y;
+ hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
+ }
+
+ float dragFlowMask = 0.0;
+ if (dragFlowStrength > 0.0) {
+ float dragRadiusPx = dragFlowRadius * resolution.y;
+ dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist);
+ }
+
+ vec2 hoverDisplacement =
+ radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 +
+ motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15;
+ vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8;
+ vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement;
+
+ float densityBoost =
+ hoverFlowStrength * hoverFlowMask * 0.22 +
+ dragFlowStrength * dragFlowMask * 0.16;
+ float rowH = baseRowH / (1.0 + densityBoost);
+
+ float offsetY = effectCoord.y;
+ float row = floor(offsetY / rowH);
+ float rowFrac = offsetY / rowH - row;
+ float rowV = (row + 0.5) * rowH / resolution.y;
+ float dy = abs(rowFrac - 0.5);
+
+ float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH;
+ float effectiveX = effectCoord.x + waveOffset;
+
+ float localCellRatio = cellRatio * (
+ 1.0 +
+ hoverFlowStrength * hoverFlowMask * 0.08 +
+ dragFlowStrength * dragFlowMask * 0.1 * motionBias
+ );
+ float cellW = rowH * localCellRatio;
+ float cellIdx = floor(effectiveX / cellW);
+ float cellFrac = (effectiveX - cellIdx * cellW) / cellW;
+ float cellU = (cellIdx + 0.5) * cellW / resolution.x;
+
+ vec2 sampleUv = vec2(
+ clamp(cellU, 0.0, 1.0),
+ clamp(rowV, 0.0, 1.0)
+ );
+
+ vec4 sceneSample = texture2D(tScene, sampleUv);
+ vec4 glowCell = texture2D(tGlow, sampleUv);
+
+ float mask = smoothstep(0.02, 0.08, sceneSample.a);
+ float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114));
+ float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114));
+ float detail = lum - avgLum;
+
+ float litLum = lum + max(detail, 0.0) * shading
+ - max(-detail, 0.0) * shading * 0.55;
+ float lightLift =
+ hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34;
+ float lightFocus = hoverLightStrength * hoverLightMask * 0.12;
+ litLum = clamp(litLum + lightLift, 0.0, 1.0);
+ litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
+ litLum = pow(litLum, max(contrast - lightFocus, 0.25));
+
+ float darkness = 1.0 - litLum;
+ float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
+ groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25));
+ float groupedDarkness = 1.0 - groupedLum;
+ darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping);
+ darkness = clamp(
+ (darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001),
+ 0.0,
+ 1.0
+ );
+
+ float shadowMask = smoothstep(0.42, 0.96, darkness);
+ darkness = mix(
+ darkness,
+ mix(darkness, 1.0, shadowMask),
+ shadowCrush
+ );
+
+ float inkBase = baseInk * smoothstep(0.03, 0.24, darkness);
+ float ink = mix(inkBase, 1.0, darkness);
+ float fill = pow(ink, 1.05) * power;
+ fill = clamp(fill, 0.0, 1.0) * mask;
+
+ float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink));
+ float dynamicBarHalfY = min(
+ dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink),
+ 0.78
+ );
+ float dx2 = abs(cellFrac - 0.5);
+ float halfFill = fill * 0.5;
+ float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
+ float capRX = dynamicBarHalf * rowH;
+ float capRY = dynamicBarHalfY * rowH;
+
+ float inDash = 0.0;
+ if (dx2 <= bodyHalfW) {
+ float edgeDist = dynamicBarHalfY - dy;
+ inDash = smoothstep(-0.03, 0.03, edgeDist);
+ } else {
+ float cdx = (dx2 - bodyHalfW) * cellW;
+ float cdy = dy * rowH;
+ float ellipseDist = sqrt(
+ (cdx * cdx) / max(capRX * capRX, 0.0001) +
+ (cdy * cdy) / max(capRY * capRY, 0.0001)
+ );
+ inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist);
+ }
+
+ inDash *= step(0.001, ink) * mask;
+ inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1);
+
+ vec4 glow = texture2D(tGlow, vUv);
+ float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114));
+ float halo = glowLum * glowStr * 0.25 * (1.0 - inDash);
+ float sharp = smoothstep(0.3, 0.5, inDash + halo);
+ vec3 color = dashColor * sharp;
+
+ gl_FragColor = vec4(color, sharp);
+
+ #include
+ #include
+ }
+`;
+
+const GLB_URL = '/illustrations/common/footer/footer.glb';
+
+const LIGHT_INTENSITY = 4;
+const FILL_LIGHT_INTENSITY = 1.33;
+const AMBIENT_LIGHT_INTENSITY = 0.28;
+const LIGHT_ANGLE_DEGREES = 167;
+const LIGHT_HEIGHT = 2.1;
+
+const MATERIAL_ROUGHNESS = 0.42;
+const MATERIAL_METALNESS = 0.16;
+
+const HALFTONE_ROWS = 124;
+const HALFTONE_CONTRAST = 2.7;
+const HALFTONE_POWER = 1;
+const HALFTONE_SHADING = 2.9;
+const HALFTONE_BASE_INK = 0.19;
+const HALFTONE_MAX_BAR = 0.25;
+const HALFTONE_CELL_RATIO = 2.2;
+const HALFTONE_CUTOFF = 0.02;
+const HALFTONE_DASH_COLOR = '#2a2a2a';
+
+const DRAG_SENSITIVITY = 0.008;
+const DRAG_FRICTION = 0.08;
+const DRAG_MOMENTUM = true;
+const BREATHE_AMOUNT = 0.04;
+const BREATHE_SPEED = 0.8;
+
+const INITIAL_ROTATION_X = -0.5858908325312259;
+const INITIAL_ROTATION_Y = 0.45733435420243096;
+const INITIAL_ROTATION_Z = 0;
+const INITIAL_TARGET_ROTATION_X = -0.5858908325312265;
+const INITIAL_TARGET_ROTATION_Y = 0.4573343542024313;
+const INITIAL_TIME_ELAPSED = 287.372499999977;
+const BASE_CAMERA_DISTANCE = 4;
+
+function createEnvironmentTexture(renderer: THREE.WebGLRenderer) {
+ const pmremGenerator = new THREE.PMREMGenerator(renderer);
+ const environmentTexture = pmremGenerator.fromScene(
+ new RoomEnvironment(),
+ 0.04,
+ ).texture;
+ pmremGenerator.dispose();
+
+ return environmentTexture;
+}
+
+type FooterInteractionState = {
+ dragging: boolean;
+ mouseX: number;
+ mouseY: number;
+ pointerX: number;
+ pointerY: number;
+ rotationX: number;
+ rotationY: number;
+ rotationZ: number;
+ targetRotationX: number;
+ targetRotationY: number;
+ velocityX: number;
+ velocityY: number;
+};
+
+function createInteractionState(): FooterInteractionState {
+ return {
+ dragging: false,
+ mouseX: 0.5,
+ mouseY: 0.5,
+ pointerX: 0,
+ pointerY: 0,
+ rotationX: INITIAL_ROTATION_X,
+ rotationY: INITIAL_ROTATION_Y,
+ rotationZ: INITIAL_ROTATION_Z,
+ targetRotationX: INITIAL_TARGET_ROTATION_X,
+ targetRotationY: INITIAL_TARGET_ROTATION_Y,
+ velocityX: 0,
+ velocityY: 0,
+ };
+}
+
+function createRenderTarget(width: number, height: number) {
+ return new THREE.WebGLRenderTarget(width, height, {
+ minFilter: THREE.LinearFilter,
+ magFilter: THREE.LinearFilter,
+ format: THREE.RGBAFormat,
+ });
+}
+
+function setPrimaryLightPosition(
+ light: THREE.DirectionalLight,
+ angleDegrees: number,
+ height: number,
+) {
+ const lightAngle = (angleDegrees * Math.PI) / 180;
+ light.position.set(
+ Math.cos(lightAngle) * 5,
+ height,
+ Math.sin(lightAngle) * 5,
+ );
+}
+
+function createFooterMaterial(environmentTexture: THREE.Texture) {
+ return new THREE.MeshPhysicalMaterial({
+ color: 0xd4d0c8,
+ roughness: MATERIAL_ROUGHNESS,
+ metalness: MATERIAL_METALNESS,
+ envMap: environmentTexture,
+ envMapIntensity: 0.25,
+ clearcoat: 0,
+ clearcoatRoughness: 0.08,
+ reflectivity: 0.5,
+ transmission: 0,
+ });
+}
+
+export function useFooterBackground({
+ mountRef,
+}: {
+ mountRef: RefObject;
+}) {
+ useEffect(() => {
+ const container = mountRef.current;
+
+ if (!container) {
+ return;
+ }
+
+ let cancelled = false;
+ let renderLoop: VisualRenderLoop | null = null;
+
+ 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 = tryCreateSiteWebGlRenderer({
+ antialias: false,
+ alpha: true,
+ onContextLost: () => {
+ renderLoop?.stop();
+ },
+ });
+
+ if (renderer === null) {
+ return;
+ }
+
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.setPixelRatio(1);
+ renderer.setClearColor(0x000000, 0);
+ renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
+
+ const canvas = renderer.domElement;
+ canvas.style.cursor = 'grab';
+ canvas.style.display = 'block';
+ canvas.style.height = '100%';
+ canvas.style.touchAction = 'none';
+ canvas.style.width = '100%';
+ container.appendChild(canvas);
+
+ const environmentTexture = createEnvironmentTexture(renderer);
+ const scene3d = new THREE.Scene();
+ scene3d.background = null;
+
+ const camera = new THREE.PerspectiveCamera(
+ 45,
+ getWidth() / getHeight(),
+ 0.1,
+ 100,
+ );
+ camera.position.z = BASE_CAMERA_DISTANCE;
+
+ const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHT_INTENSITY);
+ setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT);
+ scene3d.add(primaryLight);
+
+ const fillLight = new THREE.DirectionalLight(
+ 0xffffff,
+ FILL_LIGHT_INTENSITY,
+ );
+ fillLight.position.set(-3, -1, 1);
+ scene3d.add(fillLight);
+
+ const ambientLight = new THREE.AmbientLight(
+ 0xffffff,
+ AMBIENT_LIGHT_INTENSITY,
+ );
+ scene3d.add(ambientLight);
+
+ const material = createFooterMaterial(environmentTexture);
+ const mesh = new THREE.Mesh(new THREE.BufferGeometry(), material);
+ mesh.visible = false;
+ scene3d.add(mesh);
+
+ const sceneTarget = createRenderTarget(
+ getVirtualWidth(),
+ getVirtualHeight(),
+ );
+ const blurTargetA = createRenderTarget(
+ getVirtualWidth(),
+ getVirtualHeight(),
+ );
+ const blurTargetB = createRenderTarget(
+ getVirtualWidth(),
+ getVirtualHeight(),
+ );
+ const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
+ const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
+
+ const blurHorizontalMaterial = new THREE.ShaderMaterial({
+ uniforms: {
+ tInput: { value: null },
+ dir: { value: new THREE.Vector2(1, 0) },
+ res: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ },
+ vertexShader: passThroughVertexShader,
+ fragmentShader: blurFragmentShader,
+ });
+
+ const blurVerticalMaterial = new THREE.ShaderMaterial({
+ uniforms: {
+ tInput: { value: null },
+ dir: { value: new THREE.Vector2(0, 1) },
+ res: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ },
+ vertexShader: passThroughVertexShader,
+ fragmentShader: blurFragmentShader,
+ });
+
+ const halftoneMaterial = new THREE.ShaderMaterial({
+ transparent: true,
+ uniforms: {
+ tScene: { value: sceneTarget.texture },
+ tGlow: { value: blurTargetB.texture },
+ resolution: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ numRows: { value: HALFTONE_ROWS },
+ glowStr: { value: 0 },
+ contrast: { value: HALFTONE_CONTRAST },
+ power: { value: HALFTONE_POWER },
+ shading: { value: HALFTONE_SHADING },
+ baseInk: { value: HALFTONE_BASE_INK },
+ maxBar: { value: HALFTONE_MAX_BAR },
+ cellRatio: { value: HALFTONE_CELL_RATIO },
+ cutoff: { value: HALFTONE_CUTOFF },
+ dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) },
+ time: { value: 0 },
+ waveAmount: { value: 0 },
+ waveSpeed: { value: 1 },
+ distanceScale: { value: 1 },
+ interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
+ interactionVelocity: { value: new THREE.Vector2(0, 0) },
+ dragOffset: { value: new THREE.Vector2(0, 0) },
+ hoverLightStrength: { value: 0 },
+ hoverLightRadius: { value: 0.2 },
+ hoverFlowStrength: { value: 0 },
+ hoverFlowRadius: { value: 0.18 },
+ dragFlowStrength: { value: 0 },
+ dragFlowRadius: { value: 0.24 },
+ cropToBounds: { value: 0 },
+ },
+ vertexShader: passThroughVertexShader,
+ fragmentShader: halftoneFragmentShader,
+ });
+
+ const blurHorizontalScene = new THREE.Scene();
+ blurHorizontalScene.add(
+ new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
+ );
+
+ const blurVerticalScene = new THREE.Scene();
+ blurVerticalScene.add(
+ new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial),
+ );
+
+ const postScene = new THREE.Scene();
+ postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
+
+ const interaction = createInteractionState();
+
+ const syncSize = () => {
+ const width = getWidth();
+ const height = getHeight();
+ const virtualWidth = getVirtualWidth();
+ const virtualHeight = getVirtualHeight();
+
+ renderer.setSize(virtualWidth, virtualHeight, false);
+ camera.aspect = width / height;
+ camera.updateProjectionMatrix();
+ sceneTarget.setSize(virtualWidth, virtualHeight);
+ blurTargetA.setSize(virtualWidth, virtualHeight);
+ blurTargetB.setSize(virtualWidth, virtualHeight);
+ blurHorizontalMaterial.uniforms.res.value.set(
+ virtualWidth,
+ virtualHeight,
+ );
+ blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
+ halftoneMaterial.uniforms.resolution.value.set(
+ virtualWidth,
+ virtualHeight,
+ );
+ };
+
+ const stopObservingSize = observeElementSize(container, syncSize);
+
+ const updatePointerPosition = (event: PointerEvent) => {
+ const rect = canvas.getBoundingClientRect();
+ const width = Math.max(rect.width, 1);
+ const height = Math.max(rect.height, 1);
+
+ interaction.mouseX = THREE.MathUtils.clamp(
+ (event.clientX - rect.left) / width,
+ 0,
+ 1,
+ );
+ interaction.mouseY = THREE.MathUtils.clamp(
+ (event.clientY - rect.top) / height,
+ 0,
+ 1,
+ );
+ };
+
+ const handlePointerDown = (event: PointerEvent) => {
+ updatePointerPosition(event);
+ interaction.dragging = false;
+ interaction.pointerX = event.clientX;
+ interaction.pointerY = event.clientY;
+ interaction.velocityX = 0;
+ interaction.velocityY = 0;
+ canvas.style.cursor = 'default';
+ };
+
+ const handlePointerMove = (event: PointerEvent) => {
+ updatePointerPosition(event);
+ };
+
+ const handleWindowPointerMove = (event: PointerEvent) => {
+ updatePointerPosition(event);
+
+ if (!interaction.dragging) {
+ return;
+ }
+
+ const deltaX = (event.clientX - interaction.pointerX) * DRAG_SENSITIVITY;
+ const deltaY = (event.clientY - interaction.pointerY) * DRAG_SENSITIVITY;
+ interaction.velocityX = deltaY;
+ interaction.velocityY = deltaX;
+ interaction.targetRotationY += deltaX;
+ interaction.targetRotationX += deltaY;
+ interaction.pointerX = event.clientX;
+ interaction.pointerY = event.clientY;
+ };
+
+ const handlePointerLeave = () => {
+ if (interaction.dragging) {
+ return;
+ }
+
+ interaction.mouseX = 0.5;
+ interaction.mouseY = 0.5;
+ };
+
+ const handlePointerUp = () => {
+ interaction.dragging = false;
+ canvas.style.cursor = 'grab';
+ };
+
+ const handleWindowBlur = () => {
+ handlePointerUp();
+ handlePointerLeave();
+ };
+
+ canvas.addEventListener('pointermove', handlePointerMove);
+ canvas.addEventListener('pointerleave', handlePointerLeave);
+ canvas.addEventListener('pointerdown', handlePointerDown);
+ window.addEventListener('pointerup', handlePointerUp);
+ window.addEventListener('pointermove', handleWindowPointerMove);
+ window.addEventListener('blur', handleWindowBlur);
+
+ loadImportedGeometryFromUrl('glb', GLB_URL, 'Footer model', {
+ scaleTarget: 2,
+ })
+ .then((geometry) => {
+ if (cancelled) {
+ geometry.dispose();
+ return;
+ }
+
+ mesh.geometry.dispose();
+ mesh.geometry = geometry;
+ mesh.visible = true;
+ })
+ .catch((error) => {
+ console.error(error);
+ });
+
+ const renderFrame = (
+ _timestamp: DOMHighResTimeStamp,
+ { elapsedSeconds }: VisualRenderLoopFrame,
+ ) => {
+ if (cancelled) {
+ return;
+ }
+
+ const elapsedTime = INITIAL_TIME_ELAPSED + elapsedSeconds;
+ halftoneMaterial.uniforms.time.value = elapsedTime;
+ halftoneMaterial.uniforms.interactionUv.value.set(
+ interaction.mouseX,
+ 1 - interaction.mouseY,
+ );
+
+ if (!interaction.dragging && DRAG_MOMENTUM) {
+ interaction.targetRotationX += interaction.velocityX;
+ interaction.targetRotationY += interaction.velocityY;
+ interaction.velocityX *= 1 - DRAG_FRICTION;
+ interaction.velocityY *= 1 - DRAG_FRICTION;
+ }
+
+ interaction.rotationX +=
+ (interaction.targetRotationX - interaction.rotationX) * DRAG_FRICTION;
+ interaction.rotationY +=
+ (interaction.targetRotationY - interaction.rotationY) * DRAG_FRICTION;
+ interaction.rotationZ += (0 - interaction.rotationZ) * 0.12;
+
+ const meshScale =
+ 1 + Math.sin(elapsedTime * BREATHE_SPEED) * BREATHE_AMOUNT;
+
+ mesh.rotation.set(
+ interaction.rotationX,
+ interaction.rotationY,
+ interaction.rotationZ,
+ );
+ mesh.position.y = 0;
+ mesh.scale.setScalar(meshScale);
+
+ camera.position.x += (0 - camera.position.x) * 0.12;
+ camera.position.y += (0 - camera.position.y) * 0.12;
+ camera.position.z += (BASE_CAMERA_DISTANCE - camera.position.z) * 0.12;
+ camera.lookAt(0, 0, 0);
+ setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT);
+
+ renderer.setRenderTarget(sceneTarget);
+ renderer.clear();
+ renderer.render(scene3d, camera);
+
+ blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
+ renderer.setRenderTarget(blurTargetA);
+ renderer.render(blurHorizontalScene, orthographicCamera);
+
+ blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
+ renderer.setRenderTarget(blurTargetB);
+ renderer.render(blurVerticalScene, orthographicCamera);
+
+ blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
+ renderer.setRenderTarget(blurTargetA);
+ renderer.render(blurHorizontalScene, orthographicCamera);
+
+ blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
+ renderer.setRenderTarget(blurTargetB);
+ renderer.render(blurVerticalScene, orthographicCamera);
+
+ renderer.setRenderTarget(null);
+ renderer.clear();
+ renderer.render(postScene, orthographicCamera);
+ };
+
+ renderLoop = createVisualRenderLoop({
+ renderFrame,
+ shouldRender: () => !cancelled,
+ target: container,
+ targetVisibilityOptions: { rootMargin: '100px' },
+ });
+ renderLoop.start();
+
+ return () => {
+ cancelled = true;
+ renderLoop?.dispose();
+ stopObservingSize();
+ canvas.removeEventListener('pointermove', handlePointerMove);
+ canvas.removeEventListener('pointerleave', handlePointerLeave);
+ canvas.removeEventListener('pointerdown', handlePointerDown);
+ window.removeEventListener('pointerup', handlePointerUp);
+ window.removeEventListener('pointermove', handleWindowPointerMove);
+ window.removeEventListener('blur', handleWindowBlur);
+ blurHorizontalMaterial.dispose();
+ blurVerticalMaterial.dispose();
+ halftoneMaterial.dispose();
+ fullScreenGeometry.dispose();
+ mesh.geometry.dispose();
+ material.dispose();
+ sceneTarget.dispose();
+ blurTargetA.dispose();
+ blurTargetB.dispose();
+ environmentTexture.dispose();
+ renderer.dispose();
+
+ if (canvas.parentNode === container) {
+ container.removeChild(canvas);
+ }
+ };
+ }, [mountRef]);
+}
diff --git a/packages/twenty-website-new/src/sections/Helped/Helped.tsx b/packages/twenty-website-new/src/sections/Helped/Helped.tsx
new file mode 100644
index 0000000000..2461de6398
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/Helped.tsx
@@ -0,0 +1,41 @@
+import type { Scheme } from '@/theme';
+import { styled } from '@linaria/react';
+import type { ReactNode } from 'react';
+export type { HeadingCardType } from './types/heading-card-type';
+export type { HelpedVisualId } from './types/helped-visual-id';
+import { Scene } from './components/HelpedScene';
+
+const StyledSection = styled.section`
+ width: 100%;
+
+ &[data-scheme='light'] {
+ background-color: var(--color-white);
+ }
+
+ &[data-scheme='muted'] {
+ background-color: var(--color-neutral);
+ }
+
+ &[data-scheme='dark'] {
+ background-color: var(--color-black);
+ }
+`;
+
+type RootProps = {
+ backgroundColor?: string;
+ children: ReactNode;
+ scheme?: Scheme;
+};
+
+function Root({ backgroundColor, children, scheme }: RootProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export const Helped = { Root, Scene };
diff --git a/packages/twenty-website-new/src/sections/Helped/components/Card.tsx b/packages/twenty-website-new/src/sections/Helped/components/HelpedCard.tsx
similarity index 55%
rename from packages/twenty-website-new/src/sections/Helped/components/Card.tsx
rename to packages/twenty-website-new/src/sections/Helped/components/HelpedCard.tsx
index 7221dbc23c..873cdbb4a8 100644
--- a/packages/twenty-website-new/src/sections/Helped/components/Card.tsx
+++ b/packages/twenty-website-new/src/sections/Helped/components/HelpedCard.tsx
@@ -1,14 +1,59 @@
-import { Body, Heading } from '@/design-system/components';
+import { Body, Heading, HeadingPart } from '@/design-system/components';
import { CLIENT_ICONS } from '@/icons';
-import { LocalizedLinkButton } from '@/lib/i18n/LocalizedLinkButton';
+import { LocalizedLinkButton } from '@/lib/i18n/components/LocalizedLinkButton';
import { WebGlMount } from '@/lib/visual-runtime';
-import { HelpedCardShape } from '@/sections/Helped/components/HelpedCardShape';
-import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard';
-import { HELPED_VISUALS } from '@/sections/Helped/visuals';
import { theme } from '@/theme';
-import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
+import type { HeadingCardType } from '../types/heading-card-type';
+import { HELPED_VISUALS } from '../utils/helped-visuals-map';
+
+const HELPED_CARD_SHAPE_FILL_PATH =
+ 'M0 490V4a4 4 0 0 1 4-4h288.23c.932 0 1.856.163 2.731.48l60.814 22.09c.875.318 1.8.48 2.731.48H439a4 4 0 0 1 4 4V490a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4';
+
+const HELPED_CARD_SHAPE_STROKE_PATH =
+ 'M4 .5h288.23c.874 0 1.74.152 2.561.45l60.813 22.09c.931.338 1.912.51 2.902.51H439a3.5 3.5 0 0 1 3.5 3.5V490a3.5 3.5 0 0 1-3.5 3.5H4A3.5 3.5 0 0 1 .5 490V4A3.5 3.5 0 0 1 4 .5Z';
+
+function HelpedCardShape({
+ fillColor,
+ strokeColor,
+}: {
+ fillColor: string;
+ strokeColor: string;
+}) {
+ return (
+
+ );
+}
const CardRoot = styled.article`
display: grid;
@@ -42,6 +87,7 @@ const Rule = styled.div`
const VisualShell = styled.div`
background-color: ${theme.colors.secondary.background[100]};
border-radius: ${theme.radius(2)};
+ contain: strict;
height: 240px;
overflow: hidden;
position: relative;
@@ -59,8 +105,7 @@ const CardTitleWrap = styled.div`
`;
const CardBodyWrap = styled.div`
- color: ${theme.colors.secondary.text[80]};
- --body-sm-color: currentColor;
+ --color-text-muted: ${theme.colors.secondary.text[80]};
`;
const CtaRow = styled.div`
@@ -74,10 +119,10 @@ const LOGO_FILL = theme.colors.secondary.text[100];
type CardProps = {
card: HeadingCardType;
- renderText: (descriptor: MessageDescriptor) => string;
};
-export function Card({ card, renderText }: CardProps) {
+export function HelpedCard({ card }: CardProps) {
+ const { i18n } = useLingui();
const IconComponent = CLIENT_ICONS[card.icon];
const Visual = HELPED_VISUALS[card.illustration];
const logoWidth = 104;
@@ -102,23 +147,21 @@ export function Card({ card, renderText }: CardProps) {
-
+
+ {i18n._(card.heading)}
+
-
+
+ {i18n._(card.body)}
+
diff --git a/packages/twenty-website-new/src/sections/Helped/components/HelpedCardShape.tsx b/packages/twenty-website-new/src/sections/Helped/components/HelpedCardShape.tsx
deleted file mode 100644
index bbcc2d775b..0000000000
--- a/packages/twenty-website-new/src/sections/Helped/components/HelpedCardShape.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-const HELPED_CARD_SHAPE_FILL_PATH =
- 'M0 490V4a4 4 0 0 1 4-4h288.23c.932 0 1.856.163 2.731.48l60.814 22.09c.875.318 1.8.48 2.731.48H439a4 4 0 0 1 4 4V490a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4';
-
-const HELPED_CARD_SHAPE_STROKE_PATH =
- 'M4 .5h288.23c.874 0 1.74.152 2.561.45l60.813 22.09c.931.338 1.912.51 2.902.51H439a3.5 3.5 0 0 1 3.5 3.5V490a3.5 3.5 0 0 1-3.5 3.5H4A3.5 3.5 0 0 1 .5 490V4A3.5 3.5 0 0 1 4 .5Z';
-
-type HelpedCardShapeProps = {
- fillColor: string;
- strokeColor: string;
-};
-
-export function HelpedCardShape({
- fillColor,
- strokeColor,
-}: HelpedCardShapeProps) {
- return (
-
- );
-}
diff --git a/packages/twenty-website-new/src/sections/Helped/visuals/HelpedHalftoneModel.tsx b/packages/twenty-website-new/src/sections/Helped/components/HelpedHalftoneModel.tsx
similarity index 50%
rename from packages/twenty-website-new/src/sections/Helped/visuals/HelpedHalftoneModel.tsx
rename to packages/twenty-website-new/src/sections/Helped/components/HelpedHalftoneModel.tsx
index b4b4ce3cab..9c8d9c104a 100644
--- a/packages/twenty-website-new/src/sections/Helped/visuals/HelpedHalftoneModel.tsx
+++ b/packages/twenty-website-new/src/sections/Helped/components/HelpedHalftoneModel.tsx
@@ -9,97 +9,11 @@ import {
DEFAULT_SOLID_BACKGROUND_SETTINGS,
DEFAULT_SOLID_MATERIAL_SETTINGS,
HalftoneModelCanvas,
- type HalftoneExportPose,
type HalftoneMaterialSurface,
type HalftoneStudioSettings,
- type HalftoneToneTarget,
} from '@/lib/halftone';
-
-type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
-type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
-
-export type HelpedHalftonePose = HalftoneExportPose;
-
-export type HelpedHalftoneSettings = {
- lighting: {
- intensity: number;
- fillIntensity: number;
- ambientIntensity: number;
- angleDegrees: number;
- height: number;
- };
- material: {
- roughness: number;
- metalness: number;
- surface?: HalftoneMaterialSurface;
- color?: string;
- thickness?: number;
- refraction?: number;
- environmentPower?: number;
- };
- halftone: {
- enabled: boolean;
- scale: number;
- power: number;
- toneTarget?: HalftoneToneTarget;
- width: number;
- imageContrast?: number;
- dashColor: string;
- hoverDashColor?: string;
- };
- animation: {
- autoRotateEnabled: boolean;
- breatheEnabled: boolean;
- cameraParallaxEnabled: boolean;
- followHoverEnabled: boolean;
- followDragEnabled: boolean;
- floatEnabled: boolean;
- hoverLightEnabled: boolean;
- dragFlowEnabled: boolean;
- lightSweepEnabled: boolean;
- rotateEnabled: boolean;
- autoSpeed: number;
- autoWobble: number;
- breatheAmount: number;
- breatheSpeed: number;
- cameraParallaxAmount: number;
- cameraParallaxEase: number;
- driftAmount: number;
- hoverRange: number;
- hoverEase: number;
- hoverReturn: boolean;
- dragSens: number;
- dragFriction: number;
- dragMomentum: boolean;
- rotateAxis: HalftoneRotateAxis;
- rotatePreset: HalftoneRotatePreset;
- rotateSpeed: number;
- rotatePingPong: boolean;
- floatAmplitude: number;
- floatSpeed: number;
- lightSweepHeightRange: number;
- lightSweepRange: number;
- lightSweepSpeed: number;
- springDamping: number;
- springReturnEnabled: boolean;
- springStrength: number;
- hoverHalftoneEnabled?: boolean;
- hoverHalftonePowerShift?: number;
- hoverHalftoneRadius?: number;
- hoverHalftoneWidthShift?: number;
- hoverLightIntensity: number;
- hoverLightRadius: number;
- dragFlowDecay: number;
- dragFlowRadius: number;
- dragFlowStrength: number;
- hoverWarpStrength: number;
- hoverWarpRadius: number;
- dragWarpStrength: number;
- waveEnabled: boolean;
- waveSpeed: number;
- waveAmount: number;
- };
-};
+import type { HelpedHalftonePose } from '../types/helped-halftone-pose';
+import type { HelpedHalftoneSettings } from '../types/helped-halftone-settings';
type HelpedHalftoneModelProps = {
initialPose: HelpedHalftonePose;
diff --git a/packages/twenty-website-new/src/sections/Helped/components/Scene.tsx b/packages/twenty-website-new/src/sections/Helped/components/HelpedScene.tsx
similarity index 76%
rename from packages/twenty-website-new/src/sections/Helped/components/Scene.tsx
rename to packages/twenty-website-new/src/sections/Helped/components/HelpedScene.tsx
index fb71fcdcc4..3cfffc6fe2 100644
--- a/packages/twenty-website-new/src/sections/Helped/components/Scene.tsx
+++ b/packages/twenty-website-new/src/sections/Helped/components/HelpedScene.tsx
@@ -1,21 +1,21 @@
'use client';
-import { msg } from '@lingui/core/macro';
import {
Eyebrow,
GuideCrosshair,
Heading,
HeadingPart,
} from '@/design-system/components';
-import { useRenderMessage } from '@/lib/i18n/use-render-message';
-import { HelpedSceneScrollLayoutEffect } from '@/sections/Helped/effect-components/HelpedSceneScrollLayoutEffect';
-import type { HelpedDataType } from '@/sections/Helped/types/HelpedData';
-import { preloadHelpedVisualGeometries } from '@/sections/Helped/visuals/helped-visual-models';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
import { useEffect, useRef } from 'react';
-import { Card } from './Card';
+import { HelpedCard } from './HelpedCard';
+import { HelpedSceneScrollLayoutEffect } from '../effect-components/HelpedSceneScrollLayoutEffect';
+import type { HeadingCardType } from '../types/heading-card-type';
+import { preloadHelpedVisualGeometries } from '../utils/preload-helped-visual-geometries';
const GUIDE_INTERSECTION_TOP = '176px';
@@ -95,11 +95,11 @@ const CardPositioner = styled.div`
`;
type SceneProps = {
- data: HelpedDataType;
+ cards: HeadingCardType[];
};
-export function Scene({ data }: SceneProps) {
- const renderText = useRenderMessage();
+export function Scene({ cards }: SceneProps) {
+ const { i18n } = useLingui();
const sectionRef = useRef(null);
const innerRef = useRef(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
@@ -116,7 +116,7 @@ export function Scene({ data }: SceneProps) {
>
@@ -128,11 +128,11 @@ export function Scene({ data }: SceneProps) {
/>
-
+
+
+ {i18n._(msg`In production.`)}
+
+
- {renderText(msg`Dev teams power`)}
+ {i18n._(msg`Dev teams power`)}
- {renderText(msg`company-wide`)}
+ {i18n._(msg`company-wide`)}
- {renderText(msg`change with Twenty`)}
+ {i18n._(msg`change with Twenty`)}
- {data.cards.map((card, index) => (
+ {cards.map((card, index) => (
{
cardRefs.current[index] = element;
}}
>
-
+
))}
diff --git a/packages/twenty-website-new/src/sections/Helped/components/Money.tsx b/packages/twenty-website-new/src/sections/Helped/components/Money.tsx
new file mode 100644
index 0000000000..d530dae938
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/components/Money.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { HELPED_BASE_SETTINGS } from '../constants/helped-base-settings';
+import type { HelpedHalftonePose } from '../types/helped-halftone-pose';
+import { HelpedHalftoneModel } from './HelpedHalftoneModel';
+import { HELPED_VISUAL_MODEL_URLS } from '../constants/helped-visual-model-urls';
+
+const MONEY_SETTINGS = {
+ ...HELPED_BASE_SETTINGS,
+ halftone: {
+ enabled: true,
+ scale: 25.64,
+ power: -1.24,
+ width: 0.5,
+ imageContrast: 1,
+ dashColor: '#FEFFB7',
+ hoverDashColor: '#4A38F5',
+ },
+};
+
+const MONEY_INITIAL_POSE: HelpedHalftonePose = {
+ autoElapsed: 9.604400000000004,
+ rotateElapsed: 0,
+ rotationX: 9.425994949837918e-229,
+ rotationY: 2.798289108321203e-229,
+ rotationZ: -2.301463116952276e-231,
+ targetRotationX: 0,
+ targetRotationY: 0,
+ timeElapsed: 303.7692999998331,
+};
+
+export function Money() {
+ return (
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Helped/components/Root.tsx b/packages/twenty-website-new/src/sections/Helped/components/Root.tsx
deleted file mode 100644
index ca74c9b914..0000000000
--- a/packages/twenty-website-new/src/sections/Helped/components/Root.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { styled } from '@linaria/react';
-import type { ReactNode } from 'react';
-
-const StyledSection = styled.section`
- width: 100%;
-`;
-
-type RootProps = {
- backgroundColor: string;
- children: ReactNode;
-};
-
-export function Root({ backgroundColor, children }: RootProps) {
- return {children} ;
-}
diff --git a/packages/twenty-website-new/src/sections/Helped/components/Spaceship.tsx b/packages/twenty-website-new/src/sections/Helped/components/Spaceship.tsx
new file mode 100644
index 0000000000..c2adf6b362
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/components/Spaceship.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { HELPED_BASE_SETTINGS } from '../constants/helped-base-settings';
+import type { HelpedHalftonePose } from '../types/helped-halftone-pose';
+import { HelpedHalftoneModel } from './HelpedHalftoneModel';
+import { HELPED_VISUAL_MODEL_URLS } from '../constants/helped-visual-model-urls';
+
+const SPACESHIP_SETTINGS = {
+ ...HELPED_BASE_SETTINGS,
+ halftone: {
+ enabled: true,
+ scale: 16.64,
+ power: -1.24,
+ width: 0.5,
+ imageContrast: 1,
+ dashColor: '#89FC9A',
+ hoverDashColor: '#89FC9A',
+ },
+};
+
+const SPACESHIP_INITIAL_POSE: HelpedHalftonePose = {
+ autoElapsed: 0.05,
+ rotateElapsed: 0,
+ rotationX: 6.34639162689903e-205,
+ rotationY: 4.230976261030774e-203,
+ rotationZ: 0,
+ targetRotationX: 0,
+ targetRotationY: 0,
+ timeElapsed: 99.20160000002399,
+};
+
+export function Spaceship() {
+ return (
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Helped/components/Target.tsx b/packages/twenty-website-new/src/sections/Helped/components/Target.tsx
new file mode 100644
index 0000000000..a1c75ec660
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/components/Target.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { HELPED_BASE_SETTINGS } from '../constants/helped-base-settings';
+import type { HelpedHalftonePose } from '../types/helped-halftone-pose';
+import { HelpedHalftoneModel } from './HelpedHalftoneModel';
+import { HELPED_VISUAL_MODEL_URLS } from '../constants/helped-visual-model-urls';
+
+const TARGET_SETTINGS = {
+ ...HELPED_BASE_SETTINGS,
+ halftone: {
+ enabled: true,
+ scale: 25.64,
+ power: -1.24,
+ width: 0.5,
+ imageContrast: 1,
+ dashColor: '#ED87FC',
+ hoverDashColor: '#ED87FC',
+ },
+};
+
+const TARGET_INITIAL_POSE: HelpedHalftonePose = {
+ autoElapsed: 0.05,
+ rotateElapsed: 0,
+ rotationX: -1.6874445801116057e-120,
+ rotationY: 1.5322979021809315e-120,
+ rotationZ: -4.56607033616335e-124,
+ targetRotationX: 0,
+ targetRotationY: 0,
+ timeElapsed: 94.36910000002324,
+};
+
+export function Target() {
+ return (
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Helped/components/index.ts b/packages/twenty-website-new/src/sections/Helped/components/index.ts
deleted file mode 100644
index c5514a8351..0000000000
--- a/packages/twenty-website-new/src/sections/Helped/components/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { Root } from './Root';
-import { Scene } from './Scene';
-
-export const Helped = { Root, Scene };
diff --git a/packages/twenty-website-new/src/sections/Helped/visuals/Target.tsx b/packages/twenty-website-new/src/sections/Helped/constants/helped-base-settings.ts
similarity index 61%
rename from packages/twenty-website-new/src/sections/Helped/visuals/Target.tsx
rename to packages/twenty-website-new/src/sections/Helped/constants/helped-base-settings.ts
index 9d3493c275..e020e46f69 100644
--- a/packages/twenty-website-new/src/sections/Helped/visuals/Target.tsx
+++ b/packages/twenty-website-new/src/sections/Helped/constants/helped-base-settings.ts
@@ -1,15 +1,6 @@
-'use client';
+import type { HelpedHalftoneSettings } from '../types/helped-halftone-settings';
-import {
- HelpedHalftoneModel,
- type HelpedHalftonePose,
- type HelpedHalftoneSettings,
-} from './HelpedHalftoneModel';
-import { HELPED_VISUAL_MODEL_URLS } from './helped-visual-models';
-
-const TARGET_PREVIEW_DISTANCE = 4;
-
-const TARGET_SETTINGS: HelpedHalftoneSettings = {
+export const HELPED_BASE_SETTINGS: Omit = {
lighting: {
intensity: 3,
fillIntensity: 0,
@@ -26,15 +17,6 @@ const TARGET_SETTINGS: HelpedHalftoneSettings = {
refraction: 3,
environmentPower: 5,
},
- halftone: {
- enabled: true,
- scale: 25.64,
- power: -1.24,
- width: 0.5,
- imageContrast: 1,
- dashColor: '#ED87FC',
- hoverDashColor: '#ED87FC',
- },
animation: {
autoRotateEnabled: false,
breatheEnabled: true,
@@ -88,26 +70,3 @@ const TARGET_SETTINGS: HelpedHalftoneSettings = {
waveAmount: 2,
},
};
-
-const TARGET_INITIAL_POSE: HelpedHalftonePose = {
- autoElapsed: 0.05,
- rotateElapsed: 0,
- rotationX: -1.6874445801116057e-120,
- rotationY: 1.5322979021809315e-120,
- rotationZ: -4.56607033616335e-124,
- targetRotationX: 0,
- targetRotationY: 0,
- timeElapsed: 94.36910000002324,
-};
-
-export function Target() {
- return (
-
- );
-}
diff --git a/packages/twenty-website-new/src/sections/Helped/constants/helped-visual-model-urls.ts b/packages/twenty-website-new/src/sections/Helped/constants/helped-visual-model-urls.ts
new file mode 100644
index 0000000000..238c2b48f2
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/constants/helped-visual-model-urls.ts
@@ -0,0 +1,5 @@
+export const HELPED_VISUAL_MODEL_URLS = {
+ money: '/illustrations/home/helped/money.glb',
+ spaceship: '/illustrations/home/helped/spaceship.glb',
+ target: '/illustrations/home/helped/target.glb',
+} as const;
diff --git a/packages/twenty-website-new/src/sections/Helped/effect-components/HelpedSceneScrollLayoutEffect.tsx b/packages/twenty-website-new/src/sections/Helped/effect-components/HelpedSceneScrollLayoutEffect.tsx
index 97944aa064..6b8bdc0e5f 100644
--- a/packages/twenty-website-new/src/sections/Helped/effect-components/HelpedSceneScrollLayoutEffect.tsx
+++ b/packages/twenty-website-new/src/sections/Helped/effect-components/HelpedSceneScrollLayoutEffect.tsx
@@ -3,12 +3,10 @@
import { useCallback, useRef } from 'react';
import { useScheduledOnScroll } from '@/lib/scroll';
-import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard';
-import {
- applyHelpedSceneLayout,
- createHelpedSceneLayoutState,
- type HelpedSceneLayoutRefs,
-} from '@/sections/Helped/utils/helped-scene-layout';
+import { applyHelpedSceneLayout } from '../utils/apply-helped-scene-layout';
+import { createHelpedSceneLayoutState } from '../utils/create-helped-scene-layout-state';
+import type { HeadingCardType } from '../types/heading-card-type';
+import type { HelpedSceneLayoutRefs } from '../types/helped-scene-layout-refs';
type HelpedSceneScrollLayoutEffectProps = HelpedSceneLayoutRefs & {
cards: HeadingCardType[];
diff --git a/packages/twenty-website-new/src/sections/Helped/index.ts b/packages/twenty-website-new/src/sections/Helped/index.ts
new file mode 100644
index 0000000000..02b832690a
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/index.ts
@@ -0,0 +1 @@
+export { Helped, type HeadingCardType } from './Helped';
diff --git a/packages/twenty-website-new/src/sections/Helped/types/HeadingCard.ts b/packages/twenty-website-new/src/sections/Helped/types/HeadingCard.ts
deleted file mode 100644
index 414184cb74..0000000000
--- a/packages/twenty-website-new/src/sections/Helped/types/HeadingCard.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { MessageBody } from '@/lib/i18n/message-body';
-import type { MessageHeadingSegment } from '@/lib/i18n/message-heading-segment';
-
-export type HelpedVisualId = 'money' | 'spaceship' | 'target';
-
-export type HeadingCardType = {
- icon: string;
- illustration: HelpedVisualId;
- heading: MessageHeadingSegment;
- body: MessageBody;
- href: string;
-};
diff --git a/packages/twenty-website-new/src/sections/Helped/types/HelpedData.ts b/packages/twenty-website-new/src/sections/Helped/types/HelpedData.ts
deleted file mode 100644
index 0d3c897f99..0000000000
--- a/packages/twenty-website-new/src/sections/Helped/types/HelpedData.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { MessageEyebrow } from '@/lib/i18n/message-eyebrow';
-import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard';
-
-export type HelpedDataType = {
- eyebrow: MessageEyebrow;
- cards: HeadingCardType[];
-};
diff --git a/packages/twenty-website-new/src/sections/Helped/types/heading-card-type.ts b/packages/twenty-website-new/src/sections/Helped/types/heading-card-type.ts
new file mode 100644
index 0000000000..26cd0081d8
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/types/heading-card-type.ts
@@ -0,0 +1,11 @@
+import type { MessageDescriptor } from '@lingui/core';
+
+import type { HelpedVisualId } from './helped-visual-id';
+
+export type HeadingCardType = {
+ icon: string;
+ illustration: HelpedVisualId;
+ heading: MessageDescriptor;
+ body: MessageDescriptor;
+ href: string;
+};
diff --git a/packages/twenty-website-new/src/sections/Helped/types/helped-halftone-pose.ts b/packages/twenty-website-new/src/sections/Helped/types/helped-halftone-pose.ts
new file mode 100644
index 0000000000..771f5be8f0
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/types/helped-halftone-pose.ts
@@ -0,0 +1,3 @@
+import type { HalftoneExportPose } from '@/lib/halftone';
+
+export type HelpedHalftonePose = HalftoneExportPose;
diff --git a/packages/twenty-website-new/src/sections/Helped/types/helped-halftone-settings.ts b/packages/twenty-website-new/src/sections/Helped/types/helped-halftone-settings.ts
new file mode 100644
index 0000000000..19b100e1cb
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/types/helped-halftone-settings.ts
@@ -0,0 +1,88 @@
+import type {
+ HalftoneMaterialSurface,
+ HalftoneToneTarget,
+} from '@/lib/halftone';
+
+type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
+type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
+
+export type HelpedHalftoneSettings = {
+ lighting: {
+ intensity: number;
+ fillIntensity: number;
+ ambientIntensity: number;
+ angleDegrees: number;
+ height: number;
+ };
+ material: {
+ roughness: number;
+ metalness: number;
+ surface?: HalftoneMaterialSurface;
+ color?: string;
+ thickness?: number;
+ refraction?: number;
+ environmentPower?: number;
+ };
+ halftone: {
+ enabled: boolean;
+ scale: number;
+ power: number;
+ toneTarget?: HalftoneToneTarget;
+ width: number;
+ imageContrast?: number;
+ dashColor: string;
+ hoverDashColor?: string;
+ };
+ animation: {
+ autoRotateEnabled: boolean;
+ breatheEnabled: boolean;
+ cameraParallaxEnabled: boolean;
+ followHoverEnabled: boolean;
+ followDragEnabled: boolean;
+ floatEnabled: boolean;
+ hoverLightEnabled: boolean;
+ dragFlowEnabled: boolean;
+ lightSweepEnabled: boolean;
+ rotateEnabled: boolean;
+ autoSpeed: number;
+ autoWobble: number;
+ breatheAmount: number;
+ breatheSpeed: number;
+ cameraParallaxAmount: number;
+ cameraParallaxEase: number;
+ driftAmount: number;
+ hoverRange: number;
+ hoverEase: number;
+ hoverReturn: boolean;
+ dragSens: number;
+ dragFriction: number;
+ dragMomentum: boolean;
+ rotateAxis: HalftoneRotateAxis;
+ rotatePreset: HalftoneRotatePreset;
+ rotateSpeed: number;
+ rotatePingPong: boolean;
+ floatAmplitude: number;
+ floatSpeed: number;
+ lightSweepHeightRange: number;
+ lightSweepRange: number;
+ lightSweepSpeed: number;
+ springDamping: number;
+ springReturnEnabled: boolean;
+ springStrength: number;
+ hoverHalftoneEnabled?: boolean;
+ hoverHalftonePowerShift?: number;
+ hoverHalftoneRadius?: number;
+ hoverHalftoneWidthShift?: number;
+ hoverLightIntensity: number;
+ hoverLightRadius: number;
+ dragFlowDecay: number;
+ dragFlowRadius: number;
+ dragFlowStrength: number;
+ hoverWarpStrength: number;
+ hoverWarpRadius: number;
+ dragWarpStrength: number;
+ waveEnabled: boolean;
+ waveSpeed: number;
+ waveAmount: number;
+ };
+};
diff --git a/packages/twenty-website-new/src/sections/Helped/types/helped-scene-layout-refs.ts b/packages/twenty-website-new/src/sections/Helped/types/helped-scene-layout-refs.ts
new file mode 100644
index 0000000000..6ea2b9269c
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/types/helped-scene-layout-refs.ts
@@ -0,0 +1,7 @@
+import type { RefObject } from 'react';
+
+export type HelpedSceneLayoutRefs = {
+ cardRefs: RefObject<(HTMLDivElement | null)[]>;
+ innerRef: RefObject;
+ sectionRef: RefObject;
+};
diff --git a/packages/twenty-website-new/src/sections/Helped/types/helped-scene-layout-state.ts b/packages/twenty-website-new/src/sections/Helped/types/helped-scene-layout-state.ts
new file mode 100644
index 0000000000..72aede9561
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/types/helped-scene-layout-state.ts
@@ -0,0 +1,15 @@
+type HelpedSceneLayoutMeasurements = {
+ cardCount: number;
+ cardWidth: number;
+ innerHeight: number;
+ innerWidth: number;
+ isDesktop: boolean;
+ progressMetrics: { exitTargetTop: number; lastCardHeight: number } | null;
+ progressScale: number;
+ sectionHeight: number;
+ viewportHeight: number;
+};
+
+export type HelpedSceneLayoutState = {
+ measurements: HelpedSceneLayoutMeasurements | null;
+};
diff --git a/packages/twenty-website-new/src/sections/Helped/types/helped-visual-id.ts b/packages/twenty-website-new/src/sections/Helped/types/helped-visual-id.ts
new file mode 100644
index 0000000000..41bbf77955
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/types/helped-visual-id.ts
@@ -0,0 +1 @@
+export type HelpedVisualId = 'money' | 'spaceship' | 'target';
diff --git a/packages/twenty-website-new/src/sections/Helped/utils/helped-scene-layout.ts b/packages/twenty-website-new/src/sections/Helped/utils/apply-helped-scene-layout.ts
similarity index 93%
rename from packages/twenty-website-new/src/sections/Helped/utils/helped-scene-layout.ts
rename to packages/twenty-website-new/src/sections/Helped/utils/apply-helped-scene-layout.ts
index 1561b8eefa..98d7d07aca 100644
--- a/packages/twenty-website-new/src/sections/Helped/utils/helped-scene-layout.ts
+++ b/packages/twenty-website-new/src/sections/Helped/utils/apply-helped-scene-layout.ts
@@ -1,8 +1,10 @@
-import type { RefObject } from 'react';
-
import { getPrefersReducedMotionSnapshot } from '@/lib/motion';
-import { getStepperMdUpSnapshot } from '@/lib/stepper';
-import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard';
+import { getStepperMdUpSnapshot } from '@/sections/Stepper';
+
+import { createHelpedSceneLayoutState } from './create-helped-scene-layout-state';
+import type { HeadingCardType } from '../types/heading-card-type';
+import type { HelpedSceneLayoutRefs } from '../types/helped-scene-layout-refs';
+import type { HelpedSceneLayoutState } from '../types/helped-scene-layout-state';
const CARD_WIDTH_DESKTOP = 443;
const CARD_WIDTH_MOBILE = 360;
@@ -30,14 +32,6 @@ type HelpedSceneProgressMetrics = {
lastCardHeight: number;
};
-export type HelpedSceneLayoutState = {
- measurements: HelpedSceneLayoutMeasurements | null;
-};
-
-export function createHelpedSceneLayoutState(): HelpedSceneLayoutState {
- return { measurements: null };
-}
-
function clamp01(value: number) {
return Math.min(1, Math.max(0, value));
}
@@ -78,12 +72,6 @@ function cardLeft(
return (innerWidth - cardWidth) / 2;
}
-export type HelpedSceneLayoutRefs = {
- cardRefs: RefObject<(HTMLDivElement | null)[]>;
- innerRef: RefObject;
- sectionRef: RefObject;
-};
-
function toStableLayoutMetric(value: number) {
return Math.round(value * 100) / 100;
}
diff --git a/packages/twenty-website-new/src/sections/Helped/utils/create-helped-scene-layout-state.ts b/packages/twenty-website-new/src/sections/Helped/utils/create-helped-scene-layout-state.ts
new file mode 100644
index 0000000000..904fc1d02e
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/utils/create-helped-scene-layout-state.ts
@@ -0,0 +1,5 @@
+import type { HelpedSceneLayoutState } from '../types/helped-scene-layout-state';
+
+export function createHelpedSceneLayoutState(): HelpedSceneLayoutState {
+ return { measurements: null };
+}
diff --git a/packages/twenty-website-new/src/sections/Helped/utils/helped-visuals-map.ts b/packages/twenty-website-new/src/sections/Helped/utils/helped-visuals-map.ts
new file mode 100644
index 0000000000..8245f4ad73
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Helped/utils/helped-visuals-map.ts
@@ -0,0 +1,12 @@
+import type { ComponentType } from 'react';
+
+import { Money } from '../components/Money';
+import { Spaceship } from '../components/Spaceship';
+import { Target } from '../components/Target';
+import type { HelpedVisualId } from '../types/helped-visual-id';
+
+export const HELPED_VISUALS = {
+ money: Money,
+ spaceship: Spaceship,
+ target: Target,
+} as const satisfies Record;
diff --git a/packages/twenty-website-new/src/sections/Helped/visuals/helped-visual-models.ts b/packages/twenty-website-new/src/sections/Helped/utils/preload-helped-visual-geometries.ts
similarity index 76%
rename from packages/twenty-website-new/src/sections/Helped/visuals/helped-visual-models.ts
rename to packages/twenty-website-new/src/sections/Helped/utils/preload-helped-visual-geometries.ts
index c34af2bffe..b608e2d31e 100644
--- a/packages/twenty-website-new/src/sections/Helped/visuals/helped-visual-models.ts
+++ b/packages/twenty-website-new/src/sections/Helped/utils/preload-helped-visual-geometries.ts
@@ -1,10 +1,6 @@
import { loadImportedGeometryFromUrl } from '@/lib/halftone';
-export const HELPED_VISUAL_MODEL_URLS = {
- money: '/illustrations/home/helped/money.glb',
- spaceship: '/illustrations/home/helped/spaceship.glb',
- target: '/illustrations/home/helped/target.glb',
-} as const;
+import { HELPED_VISUAL_MODEL_URLS } from '../constants/helped-visual-model-urls';
let preloadPromise: Promise | null = null;
diff --git a/packages/twenty-website-new/src/sections/Helped/visuals/Money.tsx b/packages/twenty-website-new/src/sections/Helped/visuals/Money.tsx
deleted file mode 100644
index eba024e2f8..0000000000
--- a/packages/twenty-website-new/src/sections/Helped/visuals/Money.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-'use client';
-
-import {
- HelpedHalftoneModel,
- type HelpedHalftonePose,
- type HelpedHalftoneSettings,
-} from './HelpedHalftoneModel';
-import { HELPED_VISUAL_MODEL_URLS } from './helped-visual-models';
-
-const MONEY_PREVIEW_DISTANCE = 5;
-
-const MONEY_SETTINGS: HelpedHalftoneSettings = {
- lighting: {
- intensity: 3,
- fillIntensity: 0,
- ambientIntensity: 0,
- angleDegrees: 47,
- height: 1.4,
- },
- material: {
- surface: 'glass',
- color: '#7d7d7d',
- roughness: 0.26,
- metalness: 0.15,
- thickness: 15.58,
- refraction: 3,
- environmentPower: 5,
- },
- halftone: {
- enabled: true,
- scale: 25.64,
- power: -1.24,
- width: 0.5,
- imageContrast: 1,
- dashColor: '#FEFFB7',
- hoverDashColor: '#4A38F5',
- },
- animation: {
- autoRotateEnabled: false,
- breatheEnabled: true,
- cameraParallaxEnabled: false,
- followHoverEnabled: false,
- followDragEnabled: true,
- floatEnabled: false,
- hoverLightEnabled: false,
- dragFlowEnabled: false,
- lightSweepEnabled: false,
- rotateEnabled: false,
- autoSpeed: 0.15,
- autoWobble: 0.3,
- breatheAmount: 0.015,
- breatheSpeed: 0.25,
- cameraParallaxAmount: 0.3,
- cameraParallaxEase: 0.08,
- driftAmount: 8,
- hoverRange: 25,
- hoverEase: 0.08,
- hoverReturn: true,
- dragSens: 0.008,
- dragFriction: 0.08,
- dragMomentum: true,
- rotateAxis: 'y',
- rotatePreset: 'axis',
- rotateSpeed: 0.1,
- rotatePingPong: false,
- floatAmplitude: 0.16,
- floatSpeed: 0.8,
- lightSweepHeightRange: 0,
- lightSweepRange: 28,
- lightSweepSpeed: 0.25,
- springDamping: 0.5,
- springReturnEnabled: true,
- springStrength: 0.1,
- hoverHalftoneEnabled: false,
- hoverHalftonePowerShift: 0.42,
- hoverHalftoneRadius: 0.2,
- hoverHalftoneWidthShift: -0.18,
- hoverLightIntensity: 0.8,
- hoverLightRadius: 0.2,
- dragFlowDecay: 0.08,
- dragFlowRadius: 0.24,
- dragFlowStrength: 1.8,
- hoverWarpStrength: 3,
- hoverWarpRadius: 0.15,
- dragWarpStrength: 5,
- waveEnabled: false,
- waveSpeed: 1,
- waveAmount: 2,
- },
-};
-
-const MONEY_INITIAL_POSE: HelpedHalftonePose = {
- autoElapsed: 9.604400000000004,
- rotateElapsed: 0,
- rotationX: 9.425994949837918e-229,
- rotationY: 2.798289108321203e-229,
- rotationZ: -2.301463116952276e-231,
- targetRotationX: 0,
- targetRotationY: 0,
- timeElapsed: 303.7692999998331,
-};
-
-export function Money() {
- return (
-
- );
-}
diff --git a/packages/twenty-website-new/src/sections/Helped/visuals/Spaceship.tsx b/packages/twenty-website-new/src/sections/Helped/visuals/Spaceship.tsx
deleted file mode 100644
index d70303f8b7..0000000000
--- a/packages/twenty-website-new/src/sections/Helped/visuals/Spaceship.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-'use client';
-
-import {
- HelpedHalftoneModel,
- type HelpedHalftonePose,
- type HelpedHalftoneSettings,
-} from './HelpedHalftoneModel';
-import { HELPED_VISUAL_MODEL_URLS } from './helped-visual-models';
-
-const SPACESHIP_PREVIEW_DISTANCE = 3.5;
-
-const SPACESHIP_SETTINGS: HelpedHalftoneSettings = {
- lighting: {
- intensity: 3,
- fillIntensity: 0,
- ambientIntensity: 0,
- angleDegrees: 47,
- height: 1.4,
- },
- material: {
- surface: 'glass',
- color: '#7d7d7d',
- roughness: 0.26,
- metalness: 0.15,
- thickness: 15.58,
- refraction: 3,
- environmentPower: 5,
- },
- halftone: {
- enabled: true,
- scale: 16.64,
- power: -1.24,
- width: 0.5,
- imageContrast: 1,
- dashColor: '#89FC9A',
- hoverDashColor: '#89FC9A',
- },
- animation: {
- autoRotateEnabled: false,
- breatheEnabled: true,
- cameraParallaxEnabled: false,
- followHoverEnabled: false,
- followDragEnabled: true,
- floatEnabled: false,
- hoverLightEnabled: false,
- dragFlowEnabled: false,
- lightSweepEnabled: false,
- rotateEnabled: false,
- autoSpeed: 0.15,
- autoWobble: 0.3,
- breatheAmount: 0.015,
- breatheSpeed: 0.25,
- cameraParallaxAmount: 0.3,
- cameraParallaxEase: 0.08,
- driftAmount: 8,
- hoverRange: 25,
- hoverEase: 0.08,
- hoverReturn: true,
- dragSens: 0.008,
- dragFriction: 0.08,
- dragMomentum: true,
- rotateAxis: 'y',
- rotatePreset: 'axis',
- rotateSpeed: 0.1,
- rotatePingPong: false,
- floatAmplitude: 0.16,
- floatSpeed: 0.8,
- lightSweepHeightRange: 0,
- lightSweepRange: 28,
- lightSweepSpeed: 0.25,
- springDamping: 0.5,
- springReturnEnabled: true,
- springStrength: 0.1,
- hoverHalftoneEnabled: false,
- hoverHalftonePowerShift: 0.42,
- hoverHalftoneRadius: 0.2,
- hoverHalftoneWidthShift: -0.18,
- hoverLightIntensity: 0.8,
- hoverLightRadius: 0.2,
- dragFlowDecay: 0.08,
- dragFlowRadius: 0.24,
- dragFlowStrength: 1.8,
- hoverWarpStrength: 3,
- hoverWarpRadius: 0.15,
- dragWarpStrength: 5,
- waveEnabled: false,
- waveSpeed: 1,
- waveAmount: 2,
- },
-};
-
-const SPACESHIP_INITIAL_POSE: HelpedHalftonePose = {
- autoElapsed: 0.05,
- rotateElapsed: 0,
- rotationX: 6.34639162689903e-205,
- rotationY: 4.230976261030774e-203,
- rotationZ: 0,
- targetRotationX: 0,
- targetRotationY: 0,
- timeElapsed: 99.20160000002399,
-};
-
-export function Spaceship() {
- return (
-
- );
-}
diff --git a/packages/twenty-website-new/src/sections/Helped/visuals/index.ts b/packages/twenty-website-new/src/sections/Helped/visuals/index.ts
deleted file mode 100644
index 430cb46ab6..0000000000
--- a/packages/twenty-website-new/src/sections/Helped/visuals/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { ComponentType } from 'react';
-import type { HelpedVisualId } from '@/sections/Helped/types/HeadingCard';
-import { Money } from './Money';
-import { Spaceship } from './Spaceship';
-import { Target } from './Target';
-
-export const HELPED_VISUALS = {
- money: Money,
- spaceship: Spaceship,
- target: Target,
-} as const satisfies Record;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/Body.tsx b/packages/twenty-website-new/src/sections/Hero/components/Body.tsx
index 2294d1eed4..d247a2741d 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/Body.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/Body.tsx
@@ -1,26 +1,18 @@
import {
Body as BaseBody,
- type BodyType,
- type BodyProps,
+ type BodyAs,
+ type BodyFamily,
+ type BodySize,
+ type BodyVariant,
+ type BodyWeight,
} from '@/design-system/components/Body';
import type { Page } from '@/lib/pages';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
-const defaultHeroBodyColor = `var(--hero-body-color, ${theme.colors.primary.text[60]})`;
-
const StyledBody = styled.div`
- --body-sm-color: ${defaultHeroBodyColor};
- color: ${defaultHeroBodyColor};
-
- &[data-color-scheme='primary'] {
- --hero-body-color: ${theme.colors.primary.text[60]};
- }
-
- &[data-color-scheme='secondary'] {
- --hero-body-color: ${theme.colors.secondary.text[80]};
- }
+ color: var(--color-text-muted, ${theme.colors.primary.text[60]});
&[data-preserve-line-breaks='true'] {
white-space: pre-line;
@@ -66,59 +58,41 @@ const StyledBody = styled.div`
}
`;
-export type HeroBodyColorScheme = 'primary' | 'secondary';
-
-export type HeroBodyProps = Omit<
- BodyProps,
- 'renderText'
-> & {
+type HeroBodyProps = {
+ as?: BodyAs;
+ children: ReactNode;
+ className?: string;
+ family?: BodyFamily;
page: Page;
- colorScheme?: HeroBodyColorScheme;
preserveLineBreaks?: boolean;
- renderText?: (text: TText) => ReactNode;
+ size?: BodySize;
+ variant?: BodyVariant;
+ weight?: BodyWeight;
};
-export function Body({
+export function Body({
as,
- body,
+ children,
className,
- page,
- colorScheme,
family,
+ page,
preserveLineBreaks = false,
- renderText,
size,
variant,
weight,
-}: HeroBodyProps) {
+}: HeroBodyProps) {
return (
-
- {renderText === undefined ? (
- }
- className={className}
- family={family}
- size={size}
- variant={variant}
- weight={weight}
- />
- ) : (
-
- as={as}
- body={body}
- className={className}
- family={family}
- renderText={renderText}
- size={size}
- variant={variant}
- weight={weight}
- />
- )}
+
+
+ {children}
+
);
}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/Cta.tsx b/packages/twenty-website-new/src/sections/Hero/components/Cta.tsx
index 2d2f29a0a1..1ba29b5b8c 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/Cta.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/Cta.tsx
@@ -9,7 +9,7 @@ const CTAsContainer = styled.div`
justify-content: center;
`;
-export type HeroCtaProps = { children: ReactNode };
+type HeroCtaProps = { children: ReactNode };
export function Cta({ children }: HeroCtaProps) {
return {children} ;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/Heading.tsx b/packages/twenty-website-new/src/sections/Hero/components/Heading.tsx
index c408badc63..892b1cd1fe 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/Heading.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/Heading.tsx
@@ -21,7 +21,7 @@ const HeadingContainer = styled.div`
}
`;
-export type HeroHeadingProps = HeadingProps & {
+type HeroHeadingProps = HeadingProps & {
children?: ReactNode;
page: Page;
};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/hero-page-defaults.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/hero-page-defaults.ts
new file mode 100644
index 0000000000..e168c30fc6
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/hero-page-defaults.ts
@@ -0,0 +1,4 @@
+export type HeroPageDefaults = {
+ defaultActions: string[];
+ defaultTableWidth?: number;
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/normalizeHeroPage.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/normalize-hero-page.ts
similarity index 95%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/normalizeHeroPage.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/normalize-hero-page.ts
index 1c394450a9..454fe1a12b 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/normalizeHeroPage.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/normalize-hero-page.ts
@@ -6,10 +6,7 @@ import type {
HeroWorkflowPageDefinition,
} from '@/sections/Hero/types';
-export type HeroPageDefaults = {
- defaultActions: string[];
- defaultTableWidth?: number;
-};
+import type { HeroPageDefaults } from './hero-page-defaults';
function normalizeTablePage(
page: HeroTablePageDefinition,
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/objectPinnedActions.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/object-pinned-actions.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/objectPinnedActions.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/object-pinned-actions.ts
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/rocketObject.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/rocket-object.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/rocketObject.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Data/rocket-object.ts
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/DraggableAppWindow.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/DraggableAppWindow.tsx
index bf25279ce2..b612cedb21 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/DraggableAppWindow.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableAppWindow/DraggableAppWindow.tsx
@@ -9,14 +9,14 @@ import {
type ReactNode,
} from 'react';
import { theme } from '@/theme';
-import { VISUAL_TOKENS } from '../Shared/homeVisualTokens';
+import { VISUAL_TOKENS } from '../Shared/utils/home-visual-tokens';
import { useWindowPointerInteractions } from '../WindowInteraction/use-window-pointer-interactions';
import type {
WindowPosition as Position,
WindowSize as Size,
} from '../WindowInteraction/window-geometry';
-import { useWindowOrder } from '../WindowOrder/WindowOrderProvider';
-import { WINDOW_SHADOWS } from '../Shared/windowShadows';
+import { useWindowOrder } from '../WindowOrder/use-window-order';
+import { WINDOW_SHADOWS } from '../Shared/utils/window-shadows';
import { MacWindowBar } from './MacWindowBar';
const WINDOW_ID = 'twenty-app-window';
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ConversationPanel.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ConversationPanel.tsx
index 80fbcf6ca4..e2d2cdd5ee 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ConversationPanel.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ConversationPanel.tsx
@@ -1,13 +1,10 @@
'use client';
import { styled } from '@linaria/react';
-import { AssistantResponse } from './AssistantResponse';
-import { useConversationAutoScroll } from './use-conversation-auto-scroll';
-import { UserMessage } from './UserMessage';
-
-export type ConversationMessage =
- | { id: string; role: 'user'; text: string }
- | { id: string; role: 'assistant' };
+import { AssistantResponse } from './components/AssistantResponse';
+import type { ConversationMessage } from './types/conversation-message-types';
+import { useConversationAutoScroll } from './hooks/use-conversation-auto-scroll';
+import { UserMessage } from './components/UserMessage';
type ConversationPanelProps = {
instantComplete?: boolean;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/render-streaming-segments.test.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/__tests__/RenderStreamingSegments.test.tsx
similarity index 87%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/render-streaming-segments.test.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/__tests__/RenderStreamingSegments.test.tsx
index 62ef1b2456..4085152d84 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/render-streaming-segments.test.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/__tests__/RenderStreamingSegments.test.tsx
@@ -1,7 +1,7 @@
import { isValidElement } from 'react';
-import { renderStreamingSegments } from './render-streaming-segments';
-import type { StreamingSegment } from './streaming-text-types';
+import { renderStreamingSegments } from '../components/RenderStreamingSegments';
+import type { StreamingSegment } from '../types/streaming-text-types';
const getRenderedText = (segment: unknown): string => {
if (!isValidElement<{ children: string }>(segment)) {
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-stage.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/__tests__/assistant-response-stage.test.ts
similarity index 96%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-stage.test.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/__tests__/assistant-response-stage.test.ts
index 7980555006..e4fa973e91 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-stage.test.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/__tests__/assistant-response-stage.test.ts
@@ -2,7 +2,7 @@ import {
ASSISTANT_RESPONSE_STAGE_DELAYS,
getAssistantResponseStageTransition,
hasAssistantResponseStageReached,
-} from './assistant-response-stage';
+} from '../utils/assistant-response-stage';
describe('assistant-response-stage', () => {
it('reports whether a stage has already been reached', () => {
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/changes-summary-card-state.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/__tests__/changes-summary-card-state.test.ts
similarity index 91%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/changes-summary-card-state.test.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/__tests__/changes-summary-card-state.test.ts
index d20610468a..1470827797 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/changes-summary-card-state.test.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/__tests__/changes-summary-card-state.test.ts
@@ -2,8 +2,8 @@ import {
getChangesSummaryRowDelay,
getHiddenChangesCount,
getVisibleChanges,
-} from './changes-summary-card-state';
-import type { FileChange } from './rocketChangeset';
+} from '../utils/changes-summary-card-state';
+import type { FileChange } from '../utils/file-change-type';
const changes: FileChange[] = [
{ added: 10, path: 'one.ts', removed: 0 },
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/conversation-scroll.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/__tests__/conversation-scroll.test.ts
similarity index 58%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/conversation-scroll.test.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/__tests__/conversation-scroll.test.ts
index d1eb87902b..5024c636d1 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/conversation-scroll.test.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/__tests__/conversation-scroll.test.ts
@@ -1,12 +1,35 @@
-import {
- getConversationScrollBehavior,
- scrollConversationElementToBottom,
-} from './conversation-scroll';
+import { scrollConversationElementToBottom } from '../utils/conversation-scroll';
describe('conversation-scroll', () => {
- it('uses auto scrolling when reduced motion is preferred', () => {
- expect(getConversationScrollBehavior(true)).toBe('auto');
- expect(getConversationScrollBehavior(false)).toBe('smooth');
+ it('uses smooth scrolling and auto when reduced motion is preferred', () => {
+ const smoothElement = {
+ scrollHeight: 100,
+ scrollTo: jest.fn(),
+ scrollTop: 0,
+ };
+ const autoElement = {
+ scrollHeight: 100,
+ scrollTo: jest.fn(),
+ scrollTop: 0,
+ };
+
+ scrollConversationElementToBottom({
+ element: smoothElement,
+ prefersReducedMotion: false,
+ });
+ scrollConversationElementToBottom({
+ element: autoElement,
+ prefersReducedMotion: true,
+ });
+
+ expect(smoothElement.scrollTo).toHaveBeenCalledWith({
+ behavior: 'smooth',
+ top: 100,
+ });
+ expect(autoElement.scrollTo).toHaveBeenCalledWith({
+ behavior: 'auto',
+ top: 100,
+ });
});
it('scrolls to the bottom with native scrollTo when available', () => {
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/AssistantResponse.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/AssistantResponse.tsx
similarity index 89%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/AssistantResponse.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/AssistantResponse.tsx
index fc4837c1b6..36eb4340a5 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/AssistantResponse.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/AssistantResponse.tsx
@@ -3,14 +3,12 @@
import { styled } from '@linaria/react';
import { useMemo } from 'react';
-import {
- ASSISTANT_RESPONSE_STREAMING_STAGES,
- buildAssistantResponseSegments,
-} from './assistant-response-segments';
+import { ASSISTANT_RESPONSE_STREAMING_STAGES } from '../utils/assistant-response-streaming-stages';
+import { buildAssistantResponseSegments } from './AssistantResponseSegments';
import { AssistantResponseParagraph } from './AssistantResponseParagraph';
import { ChangesSummaryCard } from './ChangesSummaryCard';
import { ThinkingIndicator } from './ThinkingIndicator';
-import { useAssistantResponseStage } from './use-assistant-response-stage';
+import { useAssistantResponseStage } from '../hooks/use-assistant-response-stage';
const ResponseRoot = styled.div`
display: flex;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/AssistantResponseParagraph.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/AssistantResponseParagraph.tsx
similarity index 76%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/AssistantResponseParagraph.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/AssistantResponseParagraph.tsx
index 2b70c3e645..002093441c 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/AssistantResponseParagraph.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/AssistantResponseParagraph.tsx
@@ -1,12 +1,13 @@
import { styled } from '@linaria/react';
-import { TERMINAL_TOKENS } from '../terminalTokens';
+import { TERMINAL_TOKENS } from '../../utils/terminal-tokens';
import {
type AssistantResponseStage,
type AssistantResponseStreamingStage,
-} from './assistant-response-stage';
-import { CHAT_TIMINGS } from './animationTiming';
-import { StreamingText, type StreamingSegment } from './StreamingText';
+} from '../utils/assistant-response-stage';
+import { CHAT_TIMINGS } from '../utils/animation-timing';
+import { StreamingText } from './StreamingText';
+import type { StreamingSegment } from '../types/streaming-text-types';
const Paragraph = styled.p`
color: ${TERMINAL_TOKENS.text.prompt};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-segments.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/AssistantResponseSegments.tsx
similarity index 94%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-segments.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/AssistantResponseSegments.tsx
index 8d0a4f0d14..d784ca99ce 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-segments.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/AssistantResponseSegments.tsx
@@ -1,9 +1,9 @@
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
-import { TERMINAL_TOKENS } from '../terminalTokens';
-import type { AssistantResponseStreamingStage } from './assistant-response-stage';
-import type { StreamingSegment } from './StreamingText';
+import { TERMINAL_TOKENS } from '../../utils/terminal-tokens';
+import type { AssistantResponseStreamingStage } from '../utils/assistant-response-stage';
+import type { StreamingSegment } from '../types/streaming-text-types';
const InlineCode = styled.span`
background: rgba(0, 0, 0, 0.045);
@@ -57,16 +57,6 @@ const PAYLOAD_ID = 'payloads';
const COMPANIES_ID = 'companies';
const LAUNCH_SITE_ID = 'launch-sites';
-export const ASSISTANT_RESPONSE_STREAMING_STAGES = [
- 'rocket',
- 'launch',
- 'payload',
- 'customer',
- 'launchSite',
- 'actions',
- 'wrapup',
-] as const satisfies readonly AssistantResponseStreamingStage[];
-
const buildIntroAndRocketParagraph = (
onObjectCreated?: (id: string) => void,
): StreamingSegment[] => [
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryCard.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/ChangesSummaryCard.tsx
similarity index 92%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryCard.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/ChangesSummaryCard.tsx
index 8989274229..5200ca41d7 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryCard.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/ChangesSummaryCard.tsx
@@ -8,15 +8,17 @@ import {
} from '@tabler/icons-react';
import { useState } from 'react';
-import { CHAT_TIMINGS } from './animationTiming';
+import { CHAT_TIMINGS } from '../utils/animation-timing';
import {
getChangesSummaryRowDelay,
getHiddenChangesCount,
getVisibleChanges,
-} from './changes-summary-card-state';
-import { DiffAdded, DiffRemoved } from './ChangesSummaryDiffCounts';
+} from '../utils/changes-summary-card-state';
+import { DiffAdded } from './DiffAdded';
+import { DiffRemoved } from './DiffRemoved';
import { ChangesSummaryFileRow } from './ChangesSummaryFileRow';
-import { CHANGESET_TOTALS, ROCKET_CHANGESET } from './rocketChangeset';
+import { CHANGESET_TOTALS } from '../utils/changeset-totals';
+import { ROCKET_CHANGESET } from '../utils/rocket-changeset';
const CardRoot = styled.div`
animation: chatCardRise ${CHAT_TIMINGS.fileCardEnterMs}ms
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryDiffCounts.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/ChangesSummaryDiffCounts.tsx
similarity index 70%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryDiffCounts.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/ChangesSummaryDiffCounts.tsx
index 1eb8e807ba..a56513ab76 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryDiffCounts.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/ChangesSummaryDiffCounts.tsx
@@ -1,16 +1,7 @@
import { styled } from '@linaria/react';
-export const DiffAdded = styled.span`
- color: #2f7d52;
- font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
- font-weight: 600;
-`;
-
-export const DiffRemoved = styled.span`
- color: #a94a4f;
- font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
- font-weight: 600;
-`;
+import { DiffAdded } from './DiffAdded';
+import { DiffRemoved } from './DiffRemoved';
const DiffCounts = styled.span`
align-items: center;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryFileRow.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/ChangesSummaryFileRow.tsx
similarity index 96%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryFileRow.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/ChangesSummaryFileRow.tsx
index 9c081762c0..c4590563d5 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ChangesSummaryFileRow.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/ChangesSummaryFileRow.tsx
@@ -1,7 +1,7 @@
import { styled } from '@linaria/react';
import { IconChevronRight } from '@tabler/icons-react';
-import type { FileChange } from './rocketChangeset';
+import type { FileChange } from '../utils/file-change-type';
import { ChangesSummaryDiffCounts } from './ChangesSummaryDiffCounts';
const FileRow = styled.div<{ $delay: string }>`
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/DiffAdded.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/DiffAdded.ts
new file mode 100644
index 0000000000..c9cffea6c8
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/DiffAdded.ts
@@ -0,0 +1,7 @@
+import { styled } from '@linaria/react';
+
+export const DiffAdded = styled.span`
+ color: #2f7d52;
+ font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
+ font-weight: 600;
+`;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/DiffRemoved.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/DiffRemoved.ts
new file mode 100644
index 0000000000..6498b04d52
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/DiffRemoved.ts
@@ -0,0 +1,7 @@
+import { styled } from '@linaria/react';
+
+export const DiffRemoved = styled.span`
+ color: #a94a4f;
+ font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
+ font-weight: 600;
+`;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/render-streaming-segments.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/RenderStreamingSegments.tsx
similarity index 84%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/render-streaming-segments.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/RenderStreamingSegments.tsx
index 094ac357f4..c8d40bd7ee 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/render-streaming-segments.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/RenderStreamingSegments.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
-import { getStreamingSegmentLength } from './get-streaming-segment-length';
-import type { StreamingSegment } from './streaming-text-types';
+import { getStreamingSegmentLength } from '../utils/get-streaming-segment-length';
+import type { StreamingSegment } from '../types/streaming-text-types';
export const renderStreamingSegments = (
segments: ReadonlyArray,
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/StreamingText.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/StreamingText.tsx
similarity index 80%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/StreamingText.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/StreamingText.tsx
index 6ebd320c5a..54ce204f1d 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/StreamingText.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/StreamingText.tsx
@@ -2,9 +2,9 @@
import { styled } from '@linaria/react';
-import { renderStreamingSegments } from './render-streaming-segments';
-import type { StreamingSegment } from './streaming-text-types';
-import { useStreamingTextProgress } from './use-streaming-text-progress';
+import { renderStreamingSegments } from './RenderStreamingSegments';
+import type { StreamingSegment } from '../types/streaming-text-types';
+import { useStreamingTextProgress } from '../hooks/use-streaming-text-progress';
type StreamingTextProps = {
segments: ReadonlyArray;
@@ -59,5 +59,3 @@ export const StreamingText = ({
);
};
-
-export type { StreamingSegment };
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ThinkingIndicator.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/ThinkingIndicator.tsx
similarity index 100%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/ThinkingIndicator.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/ThinkingIndicator.tsx
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/UserMessage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/UserMessage.tsx
similarity index 89%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/UserMessage.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/UserMessage.tsx
index ec00786529..aba10d1e0f 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/UserMessage.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/components/UserMessage.tsx
@@ -1,8 +1,8 @@
'use client';
import { styled } from '@linaria/react';
-import { TERMINAL_TOKENS } from '../terminalTokens';
-import { CHAT_TIMINGS } from './animationTiming';
+import { TERMINAL_TOKENS } from '../../utils/terminal-tokens';
+import { CHAT_TIMINGS } from '../utils/animation-timing';
type UserMessageProps = {
instant?: boolean;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-assistant-response-stage.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/hooks/use-assistant-response-stage.ts
similarity index 95%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-assistant-response-stage.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/hooks/use-assistant-response-stage.ts
index 41b811d3c7..ee25f97284 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-assistant-response-stage.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/hooks/use-assistant-response-stage.ts
@@ -2,13 +2,13 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useTimeoutRegistry } from '@/lib/react';
-import { CHAT_TIMINGS } from './animationTiming';
+import { CHAT_TIMINGS } from '../utils/animation-timing';
import {
ASSISTANT_RESPONSE_STAGE_DELAYS,
getAssistantResponseStageTransition,
hasAssistantResponseStageReached,
type AssistantResponseStage,
-} from './assistant-response-stage';
+} from '../utils/assistant-response-stage';
type UseAssistantResponseStageOptions = {
instantComplete: boolean;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-conversation-auto-scroll.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/hooks/use-conversation-auto-scroll.ts
similarity index 94%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-conversation-auto-scroll.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/hooks/use-conversation-auto-scroll.ts
index 5ecc9c928f..c587698525 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-conversation-auto-scroll.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/hooks/use-conversation-auto-scroll.ts
@@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react';
import { createAnimationFrameLoop } from '@/lib/animation';
import { getPrefersReducedMotionSnapshot } from '@/lib/motion';
-import { scrollConversationElementToBottom } from './conversation-scroll';
+import { scrollConversationElementToBottom } from '../utils/conversation-scroll';
const getSafePrefersReducedMotionSnapshot = (): boolean =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-streaming-text-progress.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/hooks/use-streaming-text-progress.ts
similarity index 93%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-streaming-text-progress.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/hooks/use-streaming-text-progress.ts
index b29c60e36d..b748fce586 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/use-streaming-text-progress.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/hooks/use-streaming-text-progress.ts
@@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from 'react';
import { useLatestRef, useTimeoutRegistry } from '@/lib/react';
-import { getStreamingSegmentLength } from './get-streaming-segment-length';
-import type { StreamingSegment } from './streaming-text-types';
+import { getStreamingSegmentLength } from '../utils/get-streaming-segment-length';
+import type { StreamingSegment } from '../types/streaming-text-types';
type UseStreamingTextProgressOptions = {
charDurationMs: number;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/types/conversation-message-types.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/types/conversation-message-types.ts
new file mode 100644
index 0000000000..2945155cac
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/types/conversation-message-types.ts
@@ -0,0 +1,3 @@
+export type ConversationMessage =
+ | { id: string; role: 'user'; text: string }
+ | { id: string; role: 'assistant' };
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/streaming-text-types.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/types/streaming-text-types.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/streaming-text-types.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/types/streaming-text-types.ts
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/animationTiming.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/animation-timing.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/animationTiming.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/animation-timing.ts
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-stage.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/assistant-response-stage.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/assistant-response-stage.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/assistant-response-stage.ts
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/assistant-response-streaming-stages.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/assistant-response-streaming-stages.ts
new file mode 100644
index 0000000000..40589abdd4
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/assistant-response-streaming-stages.ts
@@ -0,0 +1,11 @@
+import type { AssistantResponseStreamingStage } from './assistant-response-stage';
+
+export const ASSISTANT_RESPONSE_STREAMING_STAGES = [
+ 'rocket',
+ 'launch',
+ 'payload',
+ 'customer',
+ 'launchSite',
+ 'actions',
+ 'wrapup',
+] as const satisfies readonly AssistantResponseStreamingStage[];
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/changes-summary-card-state.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/changes-summary-card-state.ts
similarity index 94%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/changes-summary-card-state.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/changes-summary-card-state.ts
index 5929e7097a..2093a8c502 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/changes-summary-card-state.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/changes-summary-card-state.ts
@@ -1,4 +1,4 @@
-import type { FileChange } from './rocketChangeset';
+import type { FileChange } from './file-change-type';
export const CHANGES_SUMMARY_COLLAPSED_FILE_COUNT = 3;
export const CHANGES_SUMMARY_ROW_BASE_DELAY_MS = 40;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/changeset-totals.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/changeset-totals.ts
new file mode 100644
index 0000000000..53a245d33c
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/changeset-totals.ts
@@ -0,0 +1,9 @@
+import { ROCKET_CHANGESET } from './rocket-changeset';
+
+export const CHANGESET_TOTALS = ROCKET_CHANGESET.reduce(
+ (acc, file) => ({
+ added: acc.added + file.added,
+ removed: acc.removed + file.removed,
+ }),
+ { added: 0, removed: 0 },
+);
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/conversation-scroll.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/conversation-scroll.ts
similarity index 93%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/conversation-scroll.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/conversation-scroll.ts
index d5fd206be2..d2ce4832f0 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/conversation-scroll.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/conversation-scroll.ts
@@ -4,7 +4,7 @@ type ScrollableConversationElement = {
scrollTo?: (options: ScrollToOptions) => void;
};
-export const getConversationScrollBehavior = (
+const getConversationScrollBehavior = (
prefersReducedMotion: boolean,
): ScrollBehavior => (prefersReducedMotion ? 'auto' : 'smooth');
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/file-change-type.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/file-change-type.ts
new file mode 100644
index 0000000000..dad822d923
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/file-change-type.ts
@@ -0,0 +1,5 @@
+export type FileChange = {
+ path: string;
+ added: number;
+ removed: number;
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/get-streaming-segment-length.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/get-streaming-segment-length.ts
similarity index 68%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/get-streaming-segment-length.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/get-streaming-segment-length.ts
index 27a18833cc..313bc96c04 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/get-streaming-segment-length.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/get-streaming-segment-length.ts
@@ -1,4 +1,4 @@
-import type { StreamingSegment } from './streaming-text-types';
+import type { StreamingSegment } from '../types/streaming-text-types';
export const getStreamingSegmentLength = (segment: StreamingSegment): number =>
segment.kind === 'text' ? segment.value.length : (segment.length ?? 1);
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/rocketChangeset.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/rocket-changeset.ts
similarity index 91%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/rocketChangeset.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/rocket-changeset.ts
index 889e06402a..6bf7ae9a53 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/rocketChangeset.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/Conversation/utils/rocket-changeset.ts
@@ -1,8 +1,4 @@
-export type FileChange = {
- path: string;
- added: number;
- removed: number;
-};
+import type { FileChange } from './file-change-type';
export const ROCKET_CHANGESET: ReadonlyArray = [
{ path: 'src/__tests__/schema.integration-test.ts', added: 412, removed: 40 },
@@ -113,11 +109,3 @@ export const ROCKET_CHANGESET: ReadonlyArray = [
{ path: 'src/views/rockets.view.ts', added: 12, removed: 32 },
{ path: 'src/views/upcoming-launches.view.ts', added: 82, removed: 0 },
];
-
-export const CHANGESET_TOTALS = ROCKET_CHANGESET.reduce(
- (acc, file) => ({
- added: acc.added + file.added,
- removed: acc.removed + file.removed,
- }),
- { added: 0, removed: 0 },
-);
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/DraggableTerminal.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/DraggableTerminal.tsx
index 931e063c15..f024836507 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/DraggableTerminal.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/DraggableTerminal.tsx
@@ -3,15 +3,15 @@
import { styled } from '@linaria/react';
import { useCallback } from 'react';
import { theme } from '@/theme';
-import { WINDOW_SHADOWS } from '../Shared/windowShadows';
-import { EDITOR_TOKENS } from './TerminalEditor/editorTokens';
-import { TerminalContent } from './TerminalContent';
-import { TerminalResizeHandles } from './TerminalResizeHandles';
-import { TerminalTopBar } from './TerminalTopBar';
-import type { TerminalToggleValue } from './TerminalToggle';
-import { TERMINAL_TOKENS } from './terminalTokens';
-import { useTerminalConversationWorkflow } from './use-terminal-conversation-workflow';
-import { useTerminalWindowLayout } from './use-terminal-window-layout';
+import { WINDOW_SHADOWS } from '../Shared/utils/window-shadows';
+import { EDITOR_TOKENS } from './TerminalEditor/utils/editor-tokens';
+import { TerminalContent } from './components/TerminalContent';
+import { TerminalResizeHandles } from './components/TerminalResizeHandles';
+import { TerminalTopBar } from './components/TerminalTopBar';
+import { useTerminalConversationWorkflow } from './hooks/use-terminal-conversation-workflow';
+import { useTerminalWindowLayout } from './hooks/use-terminal-window-layout';
+import type { TerminalToggleValue } from './types/terminal-toggle-types';
+import { TERMINAL_TOKENS } from './utils/terminal-tokens';
const Shell = styled.div<{
$isDragging: boolean;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/DiffPill.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/DiffPill.tsx
index cefd23aed7..14f3b349c5 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/DiffPill.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/DiffPill.tsx
@@ -1,7 +1,7 @@
'use client';
import { styled } from '@linaria/react';
-import { TERMINAL_TOKENS } from '../terminalTokens';
+import { TERMINAL_TOKENS } from '../utils/terminal-tokens';
type DiffPillProps = {
added: number;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/TerminalDiff.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/TerminalDiff.tsx
index 1cf5476313..fdf96560f7 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/TerminalDiff.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/TerminalDiff.tsx
@@ -2,14 +2,14 @@
import { IconArrowsVertical } from '@tabler/icons-react';
import { styled } from '@linaria/react';
-import { TERMINAL_TOKENS } from '../terminalTokens';
+import { TERMINAL_TOKENS } from '../utils/terminal-tokens';
import {
DIFF_FILES,
type DiffChunk,
type DiffFile,
type DiffToken,
type DiffTokenKind,
-} from './diffData';
+} from './diff-data';
const Root = styled.div`
background: rgba(0, 0, 0, 0.02);
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diffData.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diff-data.ts
similarity index 99%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diffData.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diff-data.ts
index d59bfa88e5..149fb07b0a 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diffData.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalDiff/diff-data.ts
@@ -1,4 +1,4 @@
-import { CHANGESET_TOTALS } from '../Conversation/rocketChangeset';
+import { CHANGESET_TOTALS } from '../Conversation/utils/changeset-totals';
export type DiffTokenKind =
| 'text'
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditor.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditor.tsx
index 2b0d48ae7d..490fd7d47e 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditor.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditor.tsx
@@ -2,17 +2,17 @@
import { styled } from '@linaria/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
-import {
- EDITOR_FILES,
- EXPLORER_NODES,
- findFileById,
- tokenizeSource,
- type EditorFile,
-} from './editorData';
-import { EDITOR_TOKENS } from './editorTokens';
-import { TerminalEditorCodeView } from './TerminalEditorCodeView';
-import { TerminalEditorExplorer } from './TerminalEditorExplorer';
-import { TerminalEditorTabs } from './TerminalEditorTabs';
+import { type EditorFile } from './types/editor-data.types';
+import { EDITOR_FILES } from './utils/editor-data';
+import { EXPLORER_NODES } from './utils/editor-explorer-nodes';
+import { tokenizeSource } from './utils/tokenize-editor-source';
+
+const findFileById = (fileId: string): EditorFile | undefined =>
+ EDITOR_FILES.find((file) => file.id === fileId);
+import { EDITOR_TOKENS } from './utils/editor-tokens';
+import { TerminalEditorCodeView } from './components/TerminalEditorCodeView';
+import { TerminalEditorExplorer } from './components/TerminalEditorExplorer';
+import { TerminalEditorTabs } from './components/TerminalEditorTabs';
import {
closeTerminalEditorFile,
getInitialTerminalEditorState,
@@ -20,7 +20,7 @@ import {
getVisibleTerminalEditorExplorerNodes,
selectTerminalEditorFile,
syncTerminalEditorStateWithGeneratedFiles,
-} from './terminal-editor-state';
+} from './utils/terminal-editor-state';
const Root = styled.div`
background: ${EDITOR_TOKENS.surface.body};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/terminal-editor-state.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/__tests__/terminal-editor-state.test.ts
similarity index 96%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/terminal-editor-state.test.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/__tests__/terminal-editor-state.test.ts
index 5f96380bdd..0ab8a8d0a0 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/terminal-editor-state.test.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/__tests__/terminal-editor-state.test.ts
@@ -1,8 +1,4 @@
-import {
- DEFAULT_EDITOR_FILE_ID,
- STARTER_EDITOR_FILE_ID,
- type ExplorerNode,
-} from './editorData';
+import { type ExplorerNode } from '../types/editor-data.types';
import {
closeTerminalEditorFile,
getInitialTerminalEditorState,
@@ -10,7 +6,10 @@ import {
getVisibleTerminalEditorExplorerNodes,
selectTerminalEditorFile,
syncTerminalEditorStateWithGeneratedFiles,
-} from './terminal-editor-state';
+} from '../utils/terminal-editor-state';
+
+const DEFAULT_EDITOR_FILE_ID = 'launch-object';
+const STARTER_EDITOR_FILE_ID = 'application-config';
describe('terminal-editor-state', () => {
it('chooses the correct fallback file for generated and starter modes', () => {
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorCodeView.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/components/TerminalEditorCodeView.tsx
similarity index 94%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorCodeView.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/components/TerminalEditorCodeView.tsx
index abe6223832..a7867efd81 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorCodeView.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/components/TerminalEditorCodeView.tsx
@@ -1,12 +1,12 @@
import { styled } from '@linaria/react';
-import { TERMINAL_TOKENS } from '../terminalTokens';
+import { TERMINAL_TOKENS } from '../../utils/terminal-tokens';
import {
type CodeLine,
type CodeToken,
type EditorFile,
type TokenKind,
-} from './editorData';
-import { EDITOR_TOKENS } from './editorTokens';
+} from '../types/editor-data.types';
+import { EDITOR_TOKENS } from '../utils/editor-tokens';
const CodeRegion = styled.div`
background: ${EDITOR_TOKENS.surface.body};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorExplorer.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/components/TerminalEditorExplorer.tsx
similarity index 95%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorExplorer.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/components/TerminalEditorExplorer.tsx
index faa1648a72..f61adb6dc2 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorExplorer.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/components/TerminalEditorExplorer.tsx
@@ -1,7 +1,10 @@
import { styled } from '@linaria/react';
-import { TERMINAL_TOKENS } from '../terminalTokens';
-import { type ExplorerNode, type FileIconKind } from './editorData';
-import { EDITOR_TOKENS } from './editorTokens';
+import { TERMINAL_TOKENS } from '../../utils/terminal-tokens';
+import {
+ type ExplorerNode,
+ type FileIconKind,
+} from '../types/editor-data.types';
+import { EDITOR_TOKENS } from '../utils/editor-tokens';
const Sidebar = styled.div`
background: ${EDITOR_TOKENS.surface.sidebar};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorTabs.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/components/TerminalEditorTabs.tsx
similarity index 93%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorTabs.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/components/TerminalEditorTabs.tsx
index d5165f47c6..566177708d 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/TerminalEditorTabs.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/components/TerminalEditorTabs.tsx
@@ -1,8 +1,8 @@
import { IconX } from '@tabler/icons-react';
import { styled } from '@linaria/react';
-import { TERMINAL_TOKENS } from '../terminalTokens';
-import { type EditorFile } from './editorData';
-import { EDITOR_TOKENS } from './editorTokens';
+import { TERMINAL_TOKENS } from '../../utils/terminal-tokens';
+import { type EditorFile } from '../types/editor-data.types';
+import { EDITOR_TOKENS } from '../utils/editor-tokens';
const TabBar = styled.div`
background: ${EDITOR_TOKENS.surface.tabBar};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/types/editor-data.types.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/types/editor-data.types.ts
new file mode 100644
index 0000000000..b9b1fe9c9d
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/types/editor-data.types.ts
@@ -0,0 +1,36 @@
+export type FileIconKind = 'ts' | 'md' | 'js' | 'git' | 'yaml' | 'cf' | 'lock';
+
+export type ExplorerNode = {
+ id: string;
+ name: string;
+ depth: number;
+} & (
+ | { kind: 'folder'; expanded: boolean; generated?: boolean }
+ | {
+ kind: 'file';
+ icon: FileIconKind;
+ iconLabel: string;
+ fileId?: string;
+ generated?: boolean;
+ }
+);
+
+export type TokenKind =
+ | 'text'
+ | 'keyword'
+ | 'function'
+ | 'string'
+ | 'property'
+ | 'identifier'
+ | 'comment';
+
+export type CodeToken = { kind: TokenKind; value: string };
+
+export type CodeLine = CodeToken[];
+
+export type EditorFile = {
+ id: string;
+ name: string;
+ path: string;
+ source: string;
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/editorData.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/editor-data.ts
similarity index 73%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/editorData.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/editor-data.ts
index e7239c9402..5988ddcca4 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/editorData.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/editor-data.ts
@@ -1,39 +1,4 @@
-export type FileIconKind = 'ts' | 'md' | 'js' | 'git' | 'yaml' | 'cf' | 'lock';
-
-export type ExplorerNode = {
- id: string;
- name: string;
- depth: number;
-} & (
- | { kind: 'folder'; expanded: boolean; generated?: boolean }
- | {
- kind: 'file';
- icon: FileIconKind;
- iconLabel: string;
- fileId?: string;
- generated?: boolean;
- }
-);
-
-export type TokenKind =
- | 'text'
- | 'keyword'
- | 'function'
- | 'string'
- | 'property'
- | 'identifier'
- | 'comment';
-
-export type CodeToken = { kind: TokenKind; value: string };
-
-export type CodeLine = CodeToken[];
-
-export type EditorFile = {
- id: string;
- name: string;
- path: string;
- source: string;
-};
+import type { EditorFile } from '../types/editor-data.types';
const schemaIdentifiersSource = `export const SCHEMA_IDS = {
rocket: {
@@ -1256,612 +1221,3 @@ export const EDITOR_FILES: ReadonlyArray = [
source: applicationConfigSource,
},
];
-
-export const DEFAULT_EDITOR_FILE_ID = 'launch-object';
-
-export const STARTER_EDITOR_FILE_ID = 'application-config';
-
-export const GENERATED_FILE_IDS: ReadonlySet = new Set([
- 'schema-identifiers',
- 'rocket-object',
- 'launch-object',
- 'payload-object',
- 'launch-site-object',
- 'view-rockets',
- 'view-launches',
- 'view-upcoming',
- 'view-past-launches',
- 'view-payloads',
- 'view-launch-sites',
- 'nav-rockets',
- 'nav-launches',
- 'nav-upcoming',
- 'nav-past-launches',
- 'nav-payloads',
- 'nav-launch-sites',
- 'cmd-fly-again',
- 'cmd-schedule-launch',
- 'cmd-retire-rocket',
- 'cmd-reschedule-launch',
- 'cmd-add-payload',
- 'cmd-upcoming-launches',
- 'cmd-book-slot',
- 'cmd-set-payload-status',
- 'cmd-set-customer-status',
- 'cmd-set-site-status',
- 'cmd-book-window',
- 'cmd-launches-from-site',
-]);
-
-export const EXPLORER_NODES: ExplorerNode[] = [
- {
- id: 'root',
- name: 'my-twenty-app',
- depth: 0,
- kind: 'folder',
- expanded: true,
- },
- { id: 'github', name: '.github', depth: 1, kind: 'folder', expanded: false },
- { id: 'src', name: 'src', depth: 1, kind: 'folder', expanded: true },
- { id: 'tests', name: '__tests__', depth: 2, kind: 'folder', expanded: false },
- { id: 'agents', name: 'agents', depth: 2, kind: 'folder', expanded: false },
- {
- id: 'command-menu-items',
- name: 'command-menu-items',
- depth: 2,
- kind: 'folder',
- expanded: true,
- },
- {
- id: 'file-cmd-add-payload',
- name: 'add-payload.command-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'cmd-add-payload',
- generated: true,
- },
- {
- id: 'file-cmd-book-slot',
- name: 'book-slot.command-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'cmd-book-slot',
- generated: true,
- },
- {
- id: 'file-cmd-book-window',
- name: 'book-window.command-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'cmd-book-window',
- generated: true,
- },
- {
- id: 'file-cmd-fly-again',
- name: 'fly-again.command-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'cmd-fly-again',
- generated: true,
- },
- {
- id: 'file-cmd-launches-from-site',
- name: 'launches-from-site.command-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'cmd-launches-from-site',
- generated: true,
- },
- {
- id: 'file-cmd-reschedule-launch',
- name: 'reschedule-launch.command-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'cmd-reschedule-launch',
- generated: true,
- },
- {
- id: 'file-cmd-retire-rocket',
- name: 'retire-rocket.command-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'cmd-retire-rocket',
- generated: true,
- },
- {
- id: 'file-cmd-schedule-launch',
- name: 'schedule-launch.command-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'cmd-schedule-launch',
- generated: true,
- },
- {
- id: 'file-cmd-set-customer-status',
- name: 'set-customer-status.command-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'cmd-set-customer-status',
- generated: true,
- },
- {
- id: 'file-cmd-set-payload-status',
- name: 'set-payload-status.command-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'cmd-set-payload-status',
- generated: true,
- },
- {
- id: 'file-cmd-set-site-status',
- name: 'set-site-status.command-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'cmd-set-site-status',
- generated: true,
- },
- {
- id: 'file-cmd-upcoming-launches',
- name: 'upcoming-launches.command-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'cmd-upcoming-launches',
- generated: true,
- },
- {
- id: 'constants',
- name: 'constants',
- depth: 2,
- kind: 'folder',
- expanded: true,
- },
- {
- id: 'file-schema-identifiers',
- name: 'schema-identifiers.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'schema-identifiers',
- generated: true,
- },
- { id: 'fields', name: 'fields', depth: 2, kind: 'folder', expanded: false },
- {
- id: 'front-components',
- name: 'front-components',
- depth: 2,
- kind: 'folder',
- expanded: false,
- },
- {
- id: 'logic-functions',
- name: 'logic-functions',
- depth: 2,
- kind: 'folder',
- expanded: false,
- },
- {
- id: 'navigation-menu-items',
- name: 'navigation-menu-items',
- depth: 2,
- kind: 'folder',
- expanded: true,
- },
- {
- id: 'file-nav-launch-sites',
- name: 'launch-sites.navigation-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'nav-launch-sites',
- generated: true,
- },
- {
- id: 'file-nav-launches',
- name: 'launches.navigation-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'nav-launches',
- generated: true,
- },
- {
- id: 'file-nav-past-launches',
- name: 'past-launches.navigation-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'nav-past-launches',
- generated: true,
- },
- {
- id: 'file-nav-payloads',
- name: 'payloads.navigation-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'nav-payloads',
- generated: true,
- },
- {
- id: 'file-nav-rockets',
- name: 'rockets.navigation-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'nav-rockets',
- generated: true,
- },
- {
- id: 'file-nav-upcoming',
- name: 'upcoming-launches.navigation-menu-item.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'nav-upcoming',
- generated: true,
- },
- { id: 'objects', name: 'objects', depth: 2, kind: 'folder', expanded: true },
- {
- id: 'file-launch-site-object',
- name: 'launch-site.object.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'launch-site-object',
- generated: true,
- },
- {
- id: 'file-launch-object',
- name: 'launch.object.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'launch-object',
- generated: true,
- },
- {
- id: 'file-payload-object',
- name: 'payload.object.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'payload-object',
- generated: true,
- },
- {
- id: 'file-rocket-object',
- name: 'rocket.object.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'rocket-object',
- generated: true,
- },
- {
- id: 'page-layouts',
- name: 'page-layouts',
- depth: 2,
- kind: 'folder',
- expanded: false,
- },
- { id: 'roles', name: 'roles', depth: 2, kind: 'folder', expanded: false },
- { id: 'skills', name: 'skills', depth: 2, kind: 'folder', expanded: false },
- { id: 'views', name: 'views', depth: 2, kind: 'folder', expanded: true },
- {
- id: 'file-view-launch-sites',
- name: 'launch-sites.view.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'view-launch-sites',
- generated: true,
- },
- {
- id: 'file-view-launches',
- name: 'launches.view.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'view-launches',
- generated: true,
- },
- {
- id: 'file-view-past-launches',
- name: 'past-launches.view.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'view-past-launches',
- generated: true,
- },
- {
- id: 'file-view-payloads',
- name: 'payloads.view.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'view-payloads',
- generated: true,
- },
- {
- id: 'file-view-rockets',
- name: 'rockets.view.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'view-rockets',
- generated: true,
- },
- {
- id: 'file-view-upcoming',
- name: 'upcoming-launches.view.ts',
- depth: 3,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'view-upcoming',
- generated: true,
- },
- {
- id: 'application-config',
- name: 'application-config.ts',
- depth: 1,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- fileId: 'application-config',
- },
- {
- id: 'gitignore',
- name: '.gitignore',
- depth: 1,
- kind: 'file',
- icon: 'git',
- iconLabel: 'GI',
- },
- {
- id: 'nvmrc',
- name: '.nvmrc',
- depth: 1,
- kind: 'file',
- icon: 'cf',
- iconLabel: 'CF',
- },
- {
- id: 'oxlintrc',
- name: '.oxlintrc.json',
- depth: 1,
- kind: 'file',
- icon: 'js',
- iconLabel: 'JS',
- },
- {
- id: 'yarnrc',
- name: '.yarnrc.yml',
- depth: 1,
- kind: 'file',
- icon: 'yaml',
- iconLabel: 'YM',
- },
- {
- id: 'llms',
- name: 'LLMS.md',
- depth: 1,
- kind: 'file',
- icon: 'md',
- iconLabel: 'MD',
- },
- {
- id: 'package',
- name: 'package.json',
- depth: 1,
- kind: 'file',
- icon: 'js',
- iconLabel: 'JS',
- },
- {
- id: 'readme',
- name: 'README.md',
- depth: 1,
- kind: 'file',
- icon: 'md',
- iconLabel: 'MD',
- },
- {
- id: 'tsconfig',
- name: 'tsconfig.json',
- depth: 1,
- kind: 'file',
- icon: 'js',
- iconLabel: 'JS',
- },
- {
- id: 'tsconfig-spec',
- name: 'tsconfig.spec.json',
- depth: 1,
- kind: 'file',
- icon: 'js',
- iconLabel: 'JS',
- },
- {
- id: 'vitest-config',
- name: 'vitest.config.ts',
- depth: 1,
- kind: 'file',
- icon: 'ts',
- iconLabel: 'TS',
- },
- {
- id: 'yarn-lock',
- name: 'yarn.lock',
- depth: 1,
- kind: 'file',
- icon: 'lock',
- iconLabel: 'LO',
- },
-];
-
-const KEYWORDS = new Set([
- 'import',
- 'export',
- 'default',
- 'const',
- 'let',
- 'var',
- 'from',
- 'return',
- 'if',
- 'else',
- 'function',
- 'enum',
- 'class',
- 'type',
- 'interface',
- 'extends',
- 'implements',
- 'true',
- 'false',
- 'null',
- 'undefined',
- 'as',
- 'new',
- 'this',
- 'typeof',
- 'async',
- 'await',
- 'for',
- 'of',
- 'in',
- 'switch',
- 'case',
- 'break',
- 'continue',
- 'throw',
-]);
-
-const tokenizeLine = (line: string): CodeLine => {
- const tokens: CodeToken[] = [];
- let textBuffer = '';
- let i = 0;
-
- const flushText = () => {
- if (textBuffer.length > 0) {
- tokens.push({ kind: 'text', value: textBuffer });
- textBuffer = '';
- }
- };
-
- const push = (token: CodeToken) => {
- flushText();
- tokens.push(token);
- };
-
- while (i < line.length) {
- const ch = line[i];
-
- if (ch === '/' && line[i + 1] === '/') {
- push({ kind: 'comment', value: line.slice(i) });
- i = line.length;
- continue;
- }
-
- if (ch === "'" || ch === '"' || ch === '`') {
- const quote = ch;
- let j = i + 1;
- while (j < line.length) {
- if (line[j] === '\\') {
- j += 2;
- continue;
- }
- if (line[j] === quote) {
- break;
- }
- j += 1;
- }
- push({ kind: 'string', value: line.slice(i, j + 1) });
- i = j + 1;
- continue;
- }
-
- if (/[a-zA-Z_$]/.test(ch)) {
- let j = i + 1;
- while (j < line.length && /[\w$]/.test(line[j])) {
- j += 1;
- }
- const word = line.slice(i, j);
-
- if (KEYWORDS.has(word)) {
- push({ kind: 'keyword', value: word });
- } else if (/^[A-Z]/.test(word)) {
- push({ kind: 'identifier', value: word });
- } else {
- let k = j;
- while (k < line.length && line[k] === ' ') {
- k += 1;
- }
- if (line[k] === '(') {
- push({ kind: 'function', value: word });
- } else if (line[k] === ':') {
- push({ kind: 'property', value: word });
- } else {
- textBuffer += word;
- }
- }
-
- i = j;
- continue;
- }
-
- textBuffer += ch;
- i += 1;
- }
-
- flushText();
- return tokens;
-};
-
-export const tokenizeSource = (source: string): CodeLine[] =>
- source.split('\n').map(tokenizeLine);
-
-export const findFileById = (fileId: string): EditorFile | undefined =>
- EDITOR_FILES.find((file) => file.id === fileId);
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/editor-explorer-nodes.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/editor-explorer-nodes.ts
new file mode 100644
index 0000000000..9d05f32f5f
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/editor-explorer-nodes.ts
@@ -0,0 +1,449 @@
+import type { ExplorerNode } from '../types/editor-data.types';
+
+export const EXPLORER_NODES: ExplorerNode[] = [
+ {
+ id: 'root',
+ name: 'my-twenty-app',
+ depth: 0,
+ kind: 'folder',
+ expanded: true,
+ },
+ { id: 'github', name: '.github', depth: 1, kind: 'folder', expanded: false },
+ { id: 'src', name: 'src', depth: 1, kind: 'folder', expanded: true },
+ { id: 'tests', name: '__tests__', depth: 2, kind: 'folder', expanded: false },
+ { id: 'agents', name: 'agents', depth: 2, kind: 'folder', expanded: false },
+ {
+ id: 'command-menu-items',
+ name: 'command-menu-items',
+ depth: 2,
+ kind: 'folder',
+ expanded: true,
+ },
+ {
+ id: 'file-cmd-add-payload',
+ name: 'add-payload.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-add-payload',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-book-slot',
+ name: 'book-slot.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-book-slot',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-book-window',
+ name: 'book-window.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-book-window',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-fly-again',
+ name: 'fly-again.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-fly-again',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-launches-from-site',
+ name: 'launches-from-site.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-launches-from-site',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-reschedule-launch',
+ name: 'reschedule-launch.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-reschedule-launch',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-retire-rocket',
+ name: 'retire-rocket.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-retire-rocket',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-schedule-launch',
+ name: 'schedule-launch.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-schedule-launch',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-set-customer-status',
+ name: 'set-customer-status.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-set-customer-status',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-set-payload-status',
+ name: 'set-payload-status.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-set-payload-status',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-set-site-status',
+ name: 'set-site-status.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-set-site-status',
+ generated: true,
+ },
+ {
+ id: 'file-cmd-upcoming-launches',
+ name: 'upcoming-launches.command-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'cmd-upcoming-launches',
+ generated: true,
+ },
+ {
+ id: 'constants',
+ name: 'constants',
+ depth: 2,
+ kind: 'folder',
+ expanded: true,
+ },
+ {
+ id: 'file-schema-identifiers',
+ name: 'schema-identifiers.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'schema-identifiers',
+ generated: true,
+ },
+ { id: 'fields', name: 'fields', depth: 2, kind: 'folder', expanded: false },
+ {
+ id: 'front-components',
+ name: 'front-components',
+ depth: 2,
+ kind: 'folder',
+ expanded: false,
+ },
+ {
+ id: 'logic-functions',
+ name: 'logic-functions',
+ depth: 2,
+ kind: 'folder',
+ expanded: false,
+ },
+ {
+ id: 'navigation-menu-items',
+ name: 'navigation-menu-items',
+ depth: 2,
+ kind: 'folder',
+ expanded: true,
+ },
+ {
+ id: 'file-nav-launch-sites',
+ name: 'launch-sites.navigation-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'nav-launch-sites',
+ generated: true,
+ },
+ {
+ id: 'file-nav-launches',
+ name: 'launches.navigation-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'nav-launches',
+ generated: true,
+ },
+ {
+ id: 'file-nav-past-launches',
+ name: 'past-launches.navigation-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'nav-past-launches',
+ generated: true,
+ },
+ {
+ id: 'file-nav-payloads',
+ name: 'payloads.navigation-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'nav-payloads',
+ generated: true,
+ },
+ {
+ id: 'file-nav-rockets',
+ name: 'rockets.navigation-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'nav-rockets',
+ generated: true,
+ },
+ {
+ id: 'file-nav-upcoming',
+ name: 'upcoming-launches.navigation-menu-item.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'nav-upcoming',
+ generated: true,
+ },
+ { id: 'objects', name: 'objects', depth: 2, kind: 'folder', expanded: true },
+ {
+ id: 'file-launch-site-object',
+ name: 'launch-site.object.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'launch-site-object',
+ generated: true,
+ },
+ {
+ id: 'file-launch-object',
+ name: 'launch.object.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'launch-object',
+ generated: true,
+ },
+ {
+ id: 'file-payload-object',
+ name: 'payload.object.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'payload-object',
+ generated: true,
+ },
+ {
+ id: 'file-rocket-object',
+ name: 'rocket.object.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'rocket-object',
+ generated: true,
+ },
+ {
+ id: 'page-layouts',
+ name: 'page-layouts',
+ depth: 2,
+ kind: 'folder',
+ expanded: false,
+ },
+ { id: 'roles', name: 'roles', depth: 2, kind: 'folder', expanded: false },
+ { id: 'skills', name: 'skills', depth: 2, kind: 'folder', expanded: false },
+ { id: 'views', name: 'views', depth: 2, kind: 'folder', expanded: true },
+ {
+ id: 'file-view-launch-sites',
+ name: 'launch-sites.view.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'view-launch-sites',
+ generated: true,
+ },
+ {
+ id: 'file-view-launches',
+ name: 'launches.view.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'view-launches',
+ generated: true,
+ },
+ {
+ id: 'file-view-past-launches',
+ name: 'past-launches.view.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'view-past-launches',
+ generated: true,
+ },
+ {
+ id: 'file-view-payloads',
+ name: 'payloads.view.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'view-payloads',
+ generated: true,
+ },
+ {
+ id: 'file-view-rockets',
+ name: 'rockets.view.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'view-rockets',
+ generated: true,
+ },
+ {
+ id: 'file-view-upcoming',
+ name: 'upcoming-launches.view.ts',
+ depth: 3,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'view-upcoming',
+ generated: true,
+ },
+ {
+ id: 'application-config',
+ name: 'application-config.ts',
+ depth: 1,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ fileId: 'application-config',
+ },
+ {
+ id: 'gitignore',
+ name: '.gitignore',
+ depth: 1,
+ kind: 'file',
+ icon: 'git',
+ iconLabel: 'GI',
+ },
+ {
+ id: 'nvmrc',
+ name: '.nvmrc',
+ depth: 1,
+ kind: 'file',
+ icon: 'cf',
+ iconLabel: 'CF',
+ },
+ {
+ id: 'oxlintrc',
+ name: '.oxlintrc.json',
+ depth: 1,
+ kind: 'file',
+ icon: 'js',
+ iconLabel: 'JS',
+ },
+ {
+ id: 'yarnrc',
+ name: '.yarnrc.yml',
+ depth: 1,
+ kind: 'file',
+ icon: 'yaml',
+ iconLabel: 'YM',
+ },
+ {
+ id: 'llms',
+ name: 'LLMS.md',
+ depth: 1,
+ kind: 'file',
+ icon: 'md',
+ iconLabel: 'MD',
+ },
+ {
+ id: 'package',
+ name: 'package.json',
+ depth: 1,
+ kind: 'file',
+ icon: 'js',
+ iconLabel: 'JS',
+ },
+ {
+ id: 'readme',
+ name: 'README.md',
+ depth: 1,
+ kind: 'file',
+ icon: 'md',
+ iconLabel: 'MD',
+ },
+ {
+ id: 'tsconfig',
+ name: 'tsconfig.json',
+ depth: 1,
+ kind: 'file',
+ icon: 'js',
+ iconLabel: 'JS',
+ },
+ {
+ id: 'tsconfig-spec',
+ name: 'tsconfig.spec.json',
+ depth: 1,
+ kind: 'file',
+ icon: 'js',
+ iconLabel: 'JS',
+ },
+ {
+ id: 'vitest-config',
+ name: 'vitest.config.ts',
+ depth: 1,
+ kind: 'file',
+ icon: 'ts',
+ iconLabel: 'TS',
+ },
+ {
+ id: 'yarn-lock',
+ name: 'yarn.lock',
+ depth: 1,
+ kind: 'file',
+ icon: 'lock',
+ iconLabel: 'LO',
+ },
+];
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/editor-generated-file-ids.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/editor-generated-file-ids.ts
new file mode 100644
index 0000000000..b7ea3b36e0
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/editor-generated-file-ids.ts
@@ -0,0 +1,31 @@
+export const GENERATED_FILE_IDS: ReadonlySet = new Set([
+ 'schema-identifiers',
+ 'rocket-object',
+ 'launch-object',
+ 'payload-object',
+ 'launch-site-object',
+ 'view-rockets',
+ 'view-launches',
+ 'view-upcoming',
+ 'view-past-launches',
+ 'view-payloads',
+ 'view-launch-sites',
+ 'nav-rockets',
+ 'nav-launches',
+ 'nav-upcoming',
+ 'nav-past-launches',
+ 'nav-payloads',
+ 'nav-launch-sites',
+ 'cmd-fly-again',
+ 'cmd-schedule-launch',
+ 'cmd-retire-rocket',
+ 'cmd-reschedule-launch',
+ 'cmd-add-payload',
+ 'cmd-upcoming-launches',
+ 'cmd-book-slot',
+ 'cmd-set-payload-status',
+ 'cmd-set-customer-status',
+ 'cmd-set-site-status',
+ 'cmd-book-window',
+ 'cmd-launches-from-site',
+]);
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/editorTokens.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/editor-tokens.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/editorTokens.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/editor-tokens.ts
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/terminal-editor-state.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/terminal-editor-state.ts
similarity index 91%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/terminal-editor-state.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/terminal-editor-state.ts
index e3d16f6cd4..78b0cd1f9d 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/terminal-editor-state.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/terminal-editor-state.ts
@@ -1,9 +1,8 @@
-import {
- DEFAULT_EDITOR_FILE_ID,
- GENERATED_FILE_IDS,
- STARTER_EDITOR_FILE_ID,
- type ExplorerNode,
-} from './editorData';
+import { type ExplorerNode } from '../types/editor-data.types';
+import { GENERATED_FILE_IDS } from './editor-generated-file-ids';
+
+const DEFAULT_EDITOR_FILE_ID = 'launch-object';
+const STARTER_EDITOR_FILE_ID = 'application-config';
export type TerminalEditorState = {
activeFileId: string;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/tokenize-editor-source.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/tokenize-editor-source.ts
new file mode 100644
index 0000000000..5ea17050fa
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalEditor/utils/tokenize-editor-source.ts
@@ -0,0 +1,123 @@
+import type { CodeLine, CodeToken } from '../types/editor-data.types';
+
+const KEYWORDS = new Set([
+ 'import',
+ 'export',
+ 'default',
+ 'const',
+ 'let',
+ 'var',
+ 'from',
+ 'return',
+ 'if',
+ 'else',
+ 'function',
+ 'enum',
+ 'class',
+ 'type',
+ 'interface',
+ 'extends',
+ 'implements',
+ 'true',
+ 'false',
+ 'null',
+ 'undefined',
+ 'as',
+ 'new',
+ 'this',
+ 'typeof',
+ 'async',
+ 'await',
+ 'for',
+ 'of',
+ 'in',
+ 'switch',
+ 'case',
+ 'break',
+ 'continue',
+ 'throw',
+]);
+
+const tokenizeLine = (line: string): CodeLine => {
+ const tokens: CodeToken[] = [];
+ let textBuffer = '';
+ let i = 0;
+
+ const flushText = () => {
+ if (textBuffer.length > 0) {
+ tokens.push({ kind: 'text', value: textBuffer });
+ textBuffer = '';
+ }
+ };
+
+ const push = (token: CodeToken) => {
+ flushText();
+ tokens.push(token);
+ };
+
+ while (i < line.length) {
+ const ch = line[i];
+
+ if (ch === '/' && line[i + 1] === '/') {
+ push({ kind: 'comment', value: line.slice(i) });
+ i = line.length;
+ continue;
+ }
+
+ if (ch === "'" || ch === '"' || ch === '`') {
+ const quote = ch;
+ let j = i + 1;
+ while (j < line.length) {
+ if (line[j] === '\\') {
+ j += 2;
+ continue;
+ }
+ if (line[j] === quote) {
+ break;
+ }
+ j += 1;
+ }
+ push({ kind: 'string', value: line.slice(i, j + 1) });
+ i = j + 1;
+ continue;
+ }
+
+ if (/[a-zA-Z_$]/.test(ch)) {
+ let j = i + 1;
+ while (j < line.length && /[\w$]/.test(line[j])) {
+ j += 1;
+ }
+ const word = line.slice(i, j);
+
+ if (KEYWORDS.has(word)) {
+ push({ kind: 'keyword', value: word });
+ } else if (/^[A-Z]/.test(word)) {
+ push({ kind: 'identifier', value: word });
+ } else {
+ let k = j;
+ while (k < line.length && line[k] === ' ') {
+ k += 1;
+ }
+ if (line[k] === '(') {
+ push({ kind: 'function', value: word });
+ } else if (line[k] === ':') {
+ push({ kind: 'property', value: word });
+ } else {
+ textBuffer += word;
+ }
+ }
+
+ i = j;
+ continue;
+ }
+
+ textBuffer += ch;
+ i += 1;
+ }
+
+ flushText();
+ return tokens;
+};
+
+export const tokenizeSource = (source: string): CodeLine[] =>
+ source.split('\n').map(tokenizeLine);
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptBox.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptBox.tsx
index 9c9a5f11ed..90c7fc61a3 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptBox.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptBox.tsx
@@ -1,10 +1,10 @@
'use client';
import { styled } from '@linaria/react';
-import { TerminalPromptFooter } from './TerminalPromptFooter';
-import { TerminalPromptMessage } from './TerminalPromptMessage';
-import { TERMINAL_TOKENS } from '../terminalTokens';
-import { useTerminalPromptEasterEgg } from './use-terminal-prompt-easter-egg';
+import { TerminalPromptFooter } from './components/TerminalPromptFooter';
+import { TerminalPromptMessage } from './components/TerminalPromptMessage';
+import { TERMINAL_TOKENS } from '../utils/terminal-tokens';
+import { useTerminalPromptEasterEgg } from './hooks/use-terminal-prompt-easter-egg';
const PromptArea = styled.div`
box-sizing: border-box;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-prompt-easter-egg-state.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/__tests__/terminal-prompt-easter-egg-state.test.ts
similarity index 97%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-prompt-easter-egg-state.test.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/__tests__/terminal-prompt-easter-egg-state.test.ts
index f42737257d..258d652afb 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-prompt-easter-egg-state.test.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/__tests__/terminal-prompt-easter-egg-state.test.ts
@@ -3,7 +3,7 @@ import {
INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE,
TERMINAL_PROMPT_EASTER_EGG_MESSAGES,
terminalPromptEasterEggReducer,
-} from './terminal-prompt-easter-egg-state';
+} from '../utils/terminal-prompt-easter-egg-state';
describe('terminalPromptEasterEggReducer', () => {
it('advances from no easter egg to the first message', () => {
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-send-button-hint-position.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/__tests__/terminal-send-button-hint-position.test.ts
similarity index 76%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-send-button-hint-position.test.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/__tests__/terminal-send-button-hint-position.test.ts
index d253da67ee..6c4993097b 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-send-button-hint-position.test.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/__tests__/terminal-send-button-hint-position.test.ts
@@ -1,4 +1,4 @@
-import { getTerminalSendButtonHintPosition } from './terminal-send-button-hint-position';
+import { getTerminalSendButtonHintPosition } from '../utils/terminal-send-button-hint-position';
describe('getTerminalSendButtonHintPosition', () => {
it('anchors the finger hint near the send button bottom-right corner', () => {
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptChip.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalPromptChip.tsx
similarity index 95%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptChip.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalPromptChip.tsx
index 34ffe743a6..a4d308d11c 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptChip.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalPromptChip.tsx
@@ -2,7 +2,7 @@
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
-import { TERMINAL_TOKENS } from '../terminalTokens';
+import { TERMINAL_TOKENS } from '../../utils/terminal-tokens';
type TerminalPromptChipProps = {
icon: ReactNode;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptFooter.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalPromptFooter.tsx
similarity index 96%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptFooter.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalPromptFooter.tsx
index fab46b8408..fa32d493fe 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptFooter.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalPromptFooter.tsx
@@ -3,7 +3,7 @@ import { IconFolder, IconGitBranch } from '@tabler/icons-react';
import { TerminalPromptChip } from './TerminalPromptChip';
import { TerminalSendButton } from './TerminalSendButton';
-import { TERMINAL_TOKENS } from '../terminalTokens';
+import { TERMINAL_TOKENS } from '../../utils/terminal-tokens';
const PromptFooter = styled.div`
align-items: center;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptMessage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalPromptMessage.tsx
similarity index 96%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptMessage.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalPromptMessage.tsx
index 0457e7a17a..3332e34fc6 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalPromptMessage.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalPromptMessage.tsx
@@ -1,6 +1,6 @@
import { styled } from '@linaria/react';
-import { TERMINAL_TOKENS } from '../terminalTokens';
+import { TERMINAL_TOKENS } from '../../utils/terminal-tokens';
const PromptTextRow = styled.div<{ $clickable?: boolean }>`
align-items: flex-start;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalSendButton.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalSendButton.tsx
similarity index 93%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalSendButton.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalSendButton.tsx
index 54386a65b8..d79fa16f1d 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalSendButton.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalSendButton.tsx
@@ -3,8 +3,8 @@
import { IconArrowBackUp, IconArrowUp } from '@tabler/icons-react';
import { styled } from '@linaria/react';
import { TerminalSendButtonFingerHint } from './TerminalSendButtonFingerHint';
-import { TERMINAL_TOKENS } from '../terminalTokens';
-import { useTerminalSendButtonHint } from './use-terminal-send-button-hint';
+import { TERMINAL_TOKENS } from '../../utils/terminal-tokens';
+import { useTerminalSendButtonHint } from '../hooks/use-terminal-send-button-hint';
type TerminalSendButtonProps = {
onClick?: () => void;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalSendButtonFingerHint.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalSendButtonFingerHint.tsx
similarity index 98%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalSendButtonFingerHint.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalSendButtonFingerHint.tsx
index 9d6870315e..ae657f4c55 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/TerminalSendButtonFingerHint.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/components/TerminalSendButtonFingerHint.tsx
@@ -2,7 +2,7 @@
import { styled } from '@linaria/react';
import { createPortal } from 'react-dom';
-import type { TerminalSendButtonHintPosition } from './terminal-send-button-hint-position';
+import type { TerminalSendButtonHintPosition } from '../types/terminal-send-button-hint-position-types';
const FINGER_ROTATION = -21;
const FINGER_SIZE = 51;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/use-terminal-prompt-easter-egg.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/hooks/use-terminal-prompt-easter-egg.ts
similarity index 87%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/use-terminal-prompt-easter-egg.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/hooks/use-terminal-prompt-easter-egg.ts
index 79321c92f6..bfc023d7d3 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/use-terminal-prompt-easter-egg.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/hooks/use-terminal-prompt-easter-egg.ts
@@ -3,8 +3,8 @@ import {
getTerminalPromptEasterEggMessage,
INITIAL_TERMINAL_PROMPT_EASTER_EGG_STATE,
terminalPromptEasterEggReducer,
-} from './terminal-prompt-easter-egg-state';
-import { TRAFFIC_LIGHTS_ESCAPE_EVENT } from '../TerminalTrafficLights/terminal-traffic-light-constants';
+} from '../utils/terminal-prompt-easter-egg-state';
+import { TRAFFIC_LIGHTS_ESCAPE_EVENT } from '../../TerminalTrafficLights/utils/terminal-traffic-light-constants';
type UseTerminalPromptEasterEggOptions = {
enabled: boolean;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/use-terminal-send-button-hint.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/hooks/use-terminal-send-button-hint.ts
similarity index 94%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/use-terminal-send-button-hint.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/hooks/use-terminal-send-button-hint.ts
index b851c40f3f..7e2eb329b8 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/use-terminal-send-button-hint.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/hooks/use-terminal-send-button-hint.ts
@@ -1,10 +1,8 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { createAnimationFrameLoop } from '@/lib/animation';
import { useTimeoutRegistry } from '@/lib/react';
-import {
- getTerminalSendButtonHintPosition,
- type TerminalSendButtonHintPosition,
-} from './terminal-send-button-hint-position';
+import { getTerminalSendButtonHintPosition } from '../utils/terminal-send-button-hint-position';
+import type { TerminalSendButtonHintPosition } from '../types/terminal-send-button-hint-position-types';
const HINT_READY_DELAY = 400;
const TERMINAL_SHELL_SELECTOR = '[data-terminal-shell="true"]';
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/types/terminal-send-button-hint-position-types.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/types/terminal-send-button-hint-position-types.ts
new file mode 100644
index 0000000000..6878f66713
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/types/terminal-send-button-hint-position-types.ts
@@ -0,0 +1,4 @@
+export type TerminalSendButtonHintPosition = {
+ left: number;
+ top: number;
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-prompt-easter-egg-state.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/utils/terminal-prompt-easter-egg-state.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-prompt-easter-egg-state.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/utils/terminal-prompt-easter-egg-state.ts
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-send-button-hint-position.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/utils/terminal-send-button-hint-position.ts
similarity index 74%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-send-button-hint-position.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/utils/terminal-send-button-hint-position.ts
index 0f679852a2..2833a8347e 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/terminal-send-button-hint-position.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalPrompt/utils/terminal-send-button-hint-position.ts
@@ -1,7 +1,4 @@
-export type TerminalSendButtonHintPosition = {
- left: number;
- top: number;
-};
+import type { TerminalSendButtonHintPosition } from '../types/terminal-send-button-hint-position-types';
const FINGER_OFFSET_RIGHT = -22;
const FINGER_OFFSET_BOTTOM = -18;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLights.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLights.tsx
index 7ce5a6c61a..5d6436ebba 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLights.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLights.tsx
@@ -5,11 +5,11 @@ import type { MouseEvent as ReactMouseEvent } from 'react';
import {
DEFAULT_TRAFFIC_LIGHT_HORIZONTAL_INSET,
TRAFFIC_LIGHT_GAP,
-} from './terminal-traffic-light-constants';
-import { TRAFFIC_LIGHT_DOT_DEFINITIONS } from './terminal-traffic-light-definitions';
-import { TerminalFlyingTrafficLights } from './TerminalFlyingTrafficLights';
-import { TerminalTrafficLightDot } from './TerminalTrafficLightDot';
-import { useTerminalTrafficLightsEscape } from './use-terminal-traffic-lights-escape';
+} from './utils/terminal-traffic-light-constants';
+import { TRAFFIC_LIGHT_DOT_DEFINITIONS } from './components/TerminalTrafficLightDefinitions';
+import { TerminalFlyingTrafficLights } from './components/TerminalFlyingTrafficLights';
+import { TerminalTrafficLightDot } from './components/TerminalTrafficLightDot';
+import { useTerminalTrafficLightsEscape } from './hooks/use-terminal-traffic-lights-escape';
const TrafficLightsContainer = styled.div<{ $horizontalInset: number }>`
align-items: center;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-physics.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/__tests__/terminal-traffic-light-physics.test.ts
similarity index 97%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-physics.test.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/__tests__/terminal-traffic-light-physics.test.ts
index b4a519b113..faf54f924e 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-physics.test.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/__tests__/terminal-traffic-light-physics.test.ts
@@ -3,7 +3,7 @@ import {
disturbTrafficLightPhysicsState,
stepTrafficLightPhysicsState,
type TrafficLightPhysicsState,
-} from './terminal-traffic-light-physics';
+} from '../utils/terminal-traffic-light-physics';
const createRandomSequence = (values: number[]) => {
let index = 0;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalFlyingTrafficLights.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/components/TerminalFlyingTrafficLights.tsx
similarity index 95%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalFlyingTrafficLights.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/components/TerminalFlyingTrafficLights.tsx
index 745c566641..501125ec3b 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalFlyingTrafficLights.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/components/TerminalFlyingTrafficLights.tsx
@@ -4,8 +4,8 @@ import { styled } from '@linaria/react';
import type { ComponentType, RefCallback } from 'react';
import { createPortal } from 'react-dom';
import { theme } from '@/theme';
-import { TRAFFIC_LIGHT_DOT_SIZE } from './terminal-traffic-light-constants';
-import { TRAFFIC_LIGHT_DOT_DEFINITIONS } from './terminal-traffic-light-definitions';
+import { TRAFFIC_LIGHT_DOT_SIZE } from '../utils/terminal-traffic-light-constants';
+import { TRAFFIC_LIGHT_DOT_DEFINITIONS } from './TerminalTrafficLightDefinitions';
const FlyingDotContainer = styled.button`
background: transparent;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-definitions.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/components/TerminalTrafficLightDefinitions.tsx
similarity index 95%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-definitions.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/components/TerminalTrafficLightDefinitions.tsx
index d7669b414a..722db82d92 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-definitions.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/components/TerminalTrafficLightDefinitions.tsx
@@ -1,4 +1,4 @@
-import { TERMINAL_TOKENS } from '../terminalTokens';
+import { TERMINAL_TOKENS } from '../../utils/terminal-tokens';
const CloseGlyph = () => (
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLightDot.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/components/TerminalTrafficLightDot.tsx
similarity index 95%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLightDot.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/components/TerminalTrafficLightDot.tsx
index ce925d117a..6e32d00e3a 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/TerminalTrafficLightDot.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/components/TerminalTrafficLightDot.tsx
@@ -4,7 +4,7 @@ import type {
RefCallback,
ComponentType,
} from 'react';
-import { TRAFFIC_LIGHT_DOT_SIZE } from './terminal-traffic-light-constants';
+import { TRAFFIC_LIGHT_DOT_SIZE } from '../utils/terminal-traffic-light-constants';
const DotButton = styled.button<{
$background: string;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/use-terminal-traffic-lights-escape.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/hooks/use-terminal-traffic-lights-escape.ts
similarity index 97%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/use-terminal-traffic-lights-escape.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/hooks/use-terminal-traffic-lights-escape.ts
index f144a87a3f..baacf7669e 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/use-terminal-traffic-lights-escape.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/hooks/use-terminal-traffic-lights-escape.ts
@@ -5,12 +5,12 @@ import {
disturbTrafficLightPhysicsState,
stepTrafficLightPhysicsState,
type TrafficLightPhysicsState,
-} from './terminal-traffic-light-physics';
+} from '../utils/terminal-traffic-light-physics';
import {
createTrafficLightReturnState,
TRAFFIC_LIGHT_DOT_SIZE,
TRAFFIC_LIGHTS_ESCAPE_EVENT,
-} from './terminal-traffic-light-constants';
+} from '../utils/terminal-traffic-light-constants';
const FLOOR_PADDING = 0;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-constants.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/utils/terminal-traffic-light-constants.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-constants.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/utils/terminal-traffic-light-constants.ts
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-physics.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/utils/terminal-traffic-light-physics.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/terminal-traffic-light-physics.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTrafficLights/utils/terminal-traffic-light-physics.ts
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-state.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/__tests__/terminal-conversation-state.test.ts
similarity index 97%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-state.test.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/__tests__/terminal-conversation-state.test.ts
index e9e1aaa23c..34d509df56 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-state.test.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/__tests__/terminal-conversation-state.test.ts
@@ -1,7 +1,7 @@
import {
INITIAL_TERMINAL_CONVERSATION_STATE,
terminalConversationReducer,
-} from './terminal-conversation-state';
+} from '../utils/terminal-conversation-state';
describe('terminalConversationReducer', () => {
it('creates one user message and one assistant message when sending a prompt', () => {
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-window-geometry.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/__tests__/terminal-window-geometry.test.ts
similarity index 98%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-window-geometry.test.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/__tests__/terminal-window-geometry.test.ts
index 90c946f0f4..c6457965b7 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-window-geometry.test.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/__tests__/terminal-window-geometry.test.ts
@@ -3,7 +3,7 @@ import {
getInitialTerminalLayout,
getTerminalTargetSize,
isTerminalMobileBounds,
-} from './terminal-window-geometry';
+} from '../utils/terminal-window-geometry';
describe('terminal-window-geometry', () => {
it('detects mobile terminal bounds from parent width', () => {
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/ClaudeLogo.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/ClaudeLogo.tsx
similarity index 83%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/ClaudeLogo.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/ClaudeLogo.tsx
index a9eb4c0743..057b77b1a7 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/ClaudeLogo.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/ClaudeLogo.tsx
@@ -9,7 +9,7 @@ export const ClaudeLogo = ({ size = 14 }: { size?: number }) => {
aria-hidden
draggable={false}
height={size}
- src="/images/shared/companies/logos/claude.png"
+ src="/images/shared/companies/logos/claude.webp"
style={{ display: 'block' }}
width={size}
/>
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/CursorLogo.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/CursorLogo.tsx
similarity index 100%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/CursorLogo.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/CursorLogo.tsx
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalContent.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/TerminalContent.tsx
similarity index 82%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalContent.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/TerminalContent.tsx
index 0b059e0949..212244455e 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalContent.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/TerminalContent.tsx
@@ -1,16 +1,13 @@
import { styled } from '@linaria/react';
-import {
- ConversationPanel,
- type ConversationMessage,
-} from './Conversation/ConversationPanel';
-import { TerminalDiff } from './TerminalDiff/TerminalDiff';
-import { TerminalEditor } from './TerminalEditor/TerminalEditor';
-import { TerminalPromptBox } from './TerminalPrompt/TerminalPromptBox';
-import type { TerminalToggleValue } from './TerminalToggle';
-import {
- CLEARED_PROMPT_TEXT,
- INITIAL_PROMPT_TEXT,
-} from './terminal-conversation-copy';
+import type { ConversationMessage } from '../Conversation/types/conversation-message-types';
+import { ConversationPanel } from '../Conversation/ConversationPanel';
+import { TerminalDiff } from '../TerminalDiff/TerminalDiff';
+import { TerminalEditor } from '../TerminalEditor/TerminalEditor';
+import { TerminalPromptBox } from '../TerminalPrompt/TerminalPromptBox';
+import type { TerminalToggleValue } from '../types/terminal-toggle-types';
+import { INITIAL_PROMPT_TEXT } from '../utils/terminal-initial-prompt-text';
+
+const CLEARED_PROMPT_TEXT = 'Ask anything…';
const Body = styled.div`
display: flex;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalResizeHandles.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/TerminalResizeHandles.tsx
similarity index 97%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalResizeHandles.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/TerminalResizeHandles.tsx
index cea7801b87..8796de75a2 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalResizeHandles.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/TerminalResizeHandles.tsx
@@ -1,6 +1,6 @@
import { styled } from '@linaria/react';
import type { PointerEvent as ReactPointerEvent } from 'react';
-import type { WindowResizeHandle } from '../WindowInteraction/window-geometry';
+import type { WindowResizeHandle } from '../../WindowInteraction/window-geometry';
type StartTerminalResize = (
handle: WindowResizeHandle,
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalToggle.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/TerminalToggle.tsx
similarity index 95%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalToggle.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/TerminalToggle.tsx
index b348af08fa..bd6494a883 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalToggle.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/TerminalToggle.tsx
@@ -4,10 +4,9 @@ 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';
+import { EDITOR_TOKENS } from '../TerminalEditor/utils/editor-tokens';
+import type { TerminalToggleValue } from '../types/terminal-toggle-types';
+import { TERMINAL_TOKENS } from '../utils/terminal-tokens';
type TerminalToggleProps = {
value?: TerminalToggleValue;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTopBar.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/TerminalTopBar.tsx
similarity index 82%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTopBar.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/TerminalTopBar.tsx
index 57b099781d..703261b761 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/TerminalTopBar.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/components/TerminalTopBar.tsx
@@ -2,12 +2,13 @@
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/TerminalTrafficLights';
+import { DiffPill } from '../TerminalDiff/DiffPill';
+import { DIFF_TOTALS } from '../TerminalDiff/diff-data';
+import { EDITOR_TOKENS } from '../TerminalEditor/utils/editor-tokens';
+import type { TerminalToggleValue } from '../types/terminal-toggle-types';
+import { TerminalTrafficLights } from '../TerminalTrafficLights/TerminalTrafficLights';
+import { TERMINAL_TOKENS } from '../utils/terminal-tokens';
+import { TerminalToggle } from './TerminalToggle';
type TerminalTopBarProps = {
onDragStart: (event: ReactPointerEvent) => void;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/use-terminal-conversation-workflow.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/hooks/use-terminal-conversation-workflow.ts
similarity index 94%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/use-terminal-conversation-workflow.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/hooks/use-terminal-conversation-workflow.ts
index e4984b92cb..8dbb7c1854 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/use-terminal-conversation-workflow.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/hooks/use-terminal-conversation-workflow.ts
@@ -1,10 +1,10 @@
import { useCallback, useReducer, useRef } from 'react';
-import type { TerminalToggleValue } from './TerminalToggle';
+import type { TerminalToggleValue } from '../types/terminal-toggle-types';
import {
INITIAL_TERMINAL_CONVERSATION_STATE,
terminalConversationReducer,
-} from './terminal-conversation-state';
+} from '../utils/terminal-conversation-state';
type UseTerminalConversationWorkflowOptions = {
onChatFinished?: () => void;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/use-terminal-window-layout.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/hooks/use-terminal-window-layout.ts
similarity index 91%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/use-terminal-window-layout.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/hooks/use-terminal-window-layout.ts
index 115458acc2..2cf00dea57 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/use-terminal-window-layout.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/hooks/use-terminal-window-layout.ts
@@ -7,13 +7,13 @@ import {
type CSSProperties,
} from 'react';
import { useTimeoutRegistry } from '@/lib/react';
-import { useWindowPointerInteractions } from '../WindowInteraction/use-window-pointer-interactions';
+import { useWindowPointerInteractions } from '../../WindowInteraction/use-window-pointer-interactions';
import type {
WindowPosition as TerminalPosition,
WindowSize as TerminalSize,
-} from '../WindowInteraction/window-geometry';
-import { useWindowOrder } from '../WindowOrder/WindowOrderProvider';
-import type { TerminalToggleValue } from './TerminalToggle';
+} from '../../WindowInteraction/window-geometry';
+import { useWindowOrder } from '../../WindowOrder/use-window-order';
+import type { TerminalToggleValue } from '../types/terminal-toggle-types';
import {
getAnchoredTerminalResizeLayout,
getInitialTerminalLayout,
@@ -23,7 +23,7 @@ import {
TERMINAL_INITIAL_WIDTH,
TERMINAL_MIN_HEIGHT,
TERMINAL_MIN_WIDTH,
-} from './terminal-window-geometry';
+} from '../utils/terminal-window-geometry';
const TERMINAL_WINDOW_ID = 'terminal-window';
const TERMINAL_ANIMATION_ENABLE_DELAY = 150;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/types/terminal-toggle-types.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/types/terminal-toggle-types.ts
new file mode 100644
index 0000000000..abc02b3ed1
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/types/terminal-toggle-types.ts
@@ -0,0 +1 @@
+export type TerminalToggleValue = 'editor' | 'ai-chat';
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-state.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/utils/terminal-conversation-state.ts
similarity index 89%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-state.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/utils/terminal-conversation-state.ts
index 4fa4b1febe..3f74cc7d7b 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-state.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/utils/terminal-conversation-state.ts
@@ -1,6 +1,6 @@
-import type { ConversationMessage } from './Conversation/ConversationPanel';
-import type { TerminalToggleValue } from './TerminalToggle';
-import { INITIAL_PROMPT_TEXT } from './terminal-conversation-copy';
+import type { ConversationMessage } from '../Conversation/types/conversation-message-types';
+import type { TerminalToggleValue } from '../types/terminal-toggle-types';
+import { INITIAL_PROMPT_TEXT } from './terminal-initial-prompt-text';
export type TerminalConversationState = {
instantComplete: boolean;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-copy.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/utils/terminal-initial-prompt-text.ts
similarity index 76%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-copy.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/utils/terminal-initial-prompt-text.ts
index c2451cb127..8bc3e2a70f 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-conversation-copy.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/utils/terminal-initial-prompt-text.ts
@@ -1,4 +1,2 @@
export 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.';
-
-export const CLEARED_PROMPT_TEXT = 'Ask anything…';
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminalTokens.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/utils/terminal-tokens.ts
similarity index 100%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminalTokens.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/utils/terminal-tokens.ts
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-window-geometry.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/utils/terminal-window-geometry.ts
similarity index 97%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-window-geometry.ts
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/utils/terminal-window-geometry.ts
index 6513ca8da4..bd017e2512 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/terminal-window-geometry.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/DraggableTerminal/utils/terminal-window-geometry.ts
@@ -2,8 +2,8 @@ import type {
WindowBounds,
WindowPosition,
WindowSize,
-} from '../WindowInteraction/window-geometry';
-import type { TerminalToggleValue } from './TerminalToggle';
+} from '../../WindowInteraction/window-geometry';
+import type { TerminalToggleValue } from '../types/terminal-toggle-types';
export const TERMINAL_INITIAL_WIDTH = 380;
export const TERMINAL_INITIAL_HEIGHT = 220;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisual.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisual.tsx
index 45bed5698d..273ca7c1e8 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisual.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/HomeVisual.tsx
@@ -7,14 +7,12 @@ import { useEffect } from 'react';
import type { HeroVisualType } from '@/sections/Hero/types';
import { DraggableAppWindow } from './DraggableAppWindow/DraggableAppWindow';
import { DraggableTerminal } from './DraggableTerminal/DraggableTerminal';
-import { COLORS } from './Shared/home-visual-theme';
+import { COLORS } from './Shared/utils/home-visual-theme';
import { HomeVisualNavbar } from './Shell/HomeVisualNavbar';
import { HomeVisualSidebar } from './Shell/HomeVisualSidebar';
import { HomeVisualViewbar } from './Shell/HomeVisualViewbar';
-import {
- preloadDeferredHomeVisualPages,
- renderPageDefinition,
-} from './Shell/home-visual-page-renderers';
+import { renderPageDefinition } from './Shell/HomeVisualPageRenderers';
+import { preloadDeferredHomeVisualPages } from './Shell/preload-deferred-home-visual-pages';
import { useHomeVisualState } from './Shell/use-home-visual-state';
import { WindowOrderProvider } from './WindowOrder/WindowOrderProvider';
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Dashboard/SalesDashboardPage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Dashboard/SalesDashboardPage.tsx
index dd41b9fb71..ef5a7f5898 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Dashboard/SalesDashboardPage.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Dashboard/SalesDashboardPage.tsx
@@ -4,7 +4,7 @@ import type {
} from '@/sections/Hero/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
-import { VISUAL_TOKENS } from '../../Shared/homeVisualTokens';
+import { VISUAL_TOKENS } from '../../Shared/utils/home-visual-tokens';
const APP_FONT = VISUAL_TOKENS.font.family;
const CARD_BACKGROUND = VISUAL_TOKENS.background.secondary;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanCard.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanCard.tsx
index 0edcf010b2..e2a8b392cd 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanCard.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/KanbanCard.tsx
@@ -17,11 +17,9 @@ import type {
HeroCellPerson,
HeroKanbanCardType,
} from '@/sections/Hero/types';
-import {
- HomeVisualFaviconLogo,
- HomeVisualPersonAvatar,
-} from '../../Shared/home-visual-identity';
-import { Chip } from '../../Shared/homeVisualChip';
+import { HomeVisualFaviconLogo } from '../../Shared/components/HomeVisualFaviconLogo';
+import { HomeVisualPersonAvatar } from '../../Shared/components/HomeVisualPersonAvatar';
+import { Chip } from '../../Shared/components/HomeVisualChip';
import {
KANBAN_PAGE_COLORS,
KANBAN_PAGE_FONT,
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/kanban-page-theme.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/kanban-page-theme.ts
index ca1b512d7f..6710ca8283 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/kanban-page-theme.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Kanban/kanban-page-theme.ts
@@ -1,4 +1,4 @@
-import { VISUAL_TOKENS } from '../../Shared/homeVisualTokens';
+import { VISUAL_TOKENS } from '../../Shared/utils/home-visual-tokens';
export const KANBAN_PAGE_FONT = VISUAL_TOKENS.font.family;
export const KANBAN_PAGE_TABLER_STROKE = 1.6;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/CheckMini.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/CheckMini.tsx
new file mode 100644
index 0000000000..848656b61d
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/CheckMini.tsx
@@ -0,0 +1,23 @@
+import { IconCheck } from '@tabler/icons-react';
+
+import {
+ TABLE_PAGE_COLORS,
+ TABLE_PAGE_TABLER_STROKE,
+} from './table-page-theme';
+
+export function CheckMini({
+ color = TABLE_PAGE_COLORS.text,
+ size = 12,
+}: {
+ color?: string;
+ size?: number;
+}) {
+ return (
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/ChevronDownMini.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/ChevronDownMini.tsx
new file mode 100644
index 0000000000..6ffa2a3be7
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/ChevronDownMini.tsx
@@ -0,0 +1,23 @@
+import { IconChevronDown } from '@tabler/icons-react';
+
+import {
+ TABLE_PAGE_COLORS,
+ TABLE_PAGE_TABLER_STROKE,
+} from './table-page-theme';
+
+export function ChevronDownMini({
+ color = TABLE_PAGE_COLORS.textTertiary,
+ size = 14,
+}: {
+ color?: string;
+ size?: number;
+}) {
+ return (
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/CloseMini.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/CloseMini.tsx
new file mode 100644
index 0000000000..ae9cfa505a
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/CloseMini.tsx
@@ -0,0 +1,23 @@
+import { IconX } from '@tabler/icons-react';
+
+import {
+ TABLE_PAGE_COLORS,
+ TABLE_PAGE_TABLER_STROKE,
+} from './table-page-theme';
+
+export function CloseMini({
+ color = TABLE_PAGE_COLORS.text,
+ size = 12,
+}: {
+ color?: string;
+ size?: number;
+}) {
+ return (
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/CopyMini.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/CopyMini.tsx
new file mode 100644
index 0000000000..9964a34406
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/CopyMini.tsx
@@ -0,0 +1,23 @@
+import { IconCopy } from '@tabler/icons-react';
+
+import {
+ TABLE_PAGE_COLORS,
+ TABLE_PAGE_TABLER_STROKE,
+} from './table-page-theme';
+
+export function CopyMini({
+ color = TABLE_PAGE_COLORS.textSecondary,
+ size = 14,
+}: {
+ color?: string;
+ size?: number;
+}) {
+ return (
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/PencilMini.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/PencilMini.tsx
new file mode 100644
index 0000000000..f8cc6bbc9f
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/PencilMini.tsx
@@ -0,0 +1,23 @@
+import { IconPencil } from '@tabler/icons-react';
+
+import {
+ TABLE_PAGE_COLORS,
+ TABLE_PAGE_TABLER_STROKE,
+} from './table-page-theme';
+
+export function PencilMini({
+ color = TABLE_PAGE_COLORS.textSecondary,
+ size = 14,
+}: {
+ color?: string;
+ size?: number;
+}) {
+ return (
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/PlusMini.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/PlusMini.tsx
new file mode 100644
index 0000000000..ac254dc1ee
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/PlusMini.tsx
@@ -0,0 +1,23 @@
+import { IconPlus } from '@tabler/icons-react';
+
+import {
+ TABLE_PAGE_COLORS,
+ TABLE_PAGE_TABLER_STROKE,
+} from './table-page-theme';
+
+export function PlusMini({
+ color = TABLE_PAGE_COLORS.textSecondary,
+ size = 14,
+}: {
+ color?: string;
+ size?: number;
+}) {
+ return (
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePage.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePage.tsx
index dc57a7bfb1..2477ff3220 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePage.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePage.tsx
@@ -7,11 +7,9 @@ import { useState } from 'react';
import type { HeroTablePageDefinition } from '@/sections/Hero/types';
import { TablePageCheckbox } from './TablePageCheckbox';
import { renderTableCellValue } from './TablePageCellValue';
-import {
- ChevronDownMini,
- PlusMini,
- renderTableHeaderIcon,
-} from './TablePageMiniIcons';
+import { ChevronDownMini } from './ChevronDownMini';
+import { PlusMini } from './PlusMini';
+import { renderTableHeaderIcon } from './render-table-header-icon';
import {
TABLE_PAGE_CELL_HORIZONTAL_PADDING,
TABLE_PAGE_COLORS,
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageCellValue.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageCellValue.tsx
index 18792cbd6e..02cb0b64bf 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageCellValue.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageCellValue.tsx
@@ -9,20 +9,17 @@ import type {
HeroCellText,
HeroCellValue,
} from '@/sections/Hero/types';
-import {
- getHomeVisualInitials,
- HomeVisualAvatar,
- HomeVisualFaviconLogo,
- HomeVisualPersonAvatar,
-} from '../../Shared/home-visual-identity';
-import { Chip, ChipVariant } from '../../Shared/homeVisualChip';
-import { VISUAL_TOKENS } from '../../Shared/homeVisualTokens';
-import {
- CheckMini,
- CloseMini,
- CopyMini,
- PencilMini,
-} from './TablePageMiniIcons';
+import { getHomeVisualInitials } from '../../Shared/utils/get-home-visual-initials';
+import { HomeVisualAvatar } from '../../Shared/components/HomeVisualAvatar';
+import { HomeVisualFaviconLogo } from '../../Shared/components/HomeVisualFaviconLogo';
+import { HomeVisualPersonAvatar } from '../../Shared/components/HomeVisualPersonAvatar';
+import { ChipVariant } from '../../Shared/utils/chip-variant';
+import { Chip } from '../../Shared/components/HomeVisualChip';
+import { VISUAL_TOKENS } from '../../Shared/utils/home-visual-tokens';
+import { CheckMini } from './CheckMini';
+import { CloseMini } from './CloseMini';
+import { CopyMini } from './CopyMini';
+import { PencilMini } from './PencilMini';
import { TablePageCheckbox } from './TablePageCheckbox';
import {
TABLE_PAGE_CELL_HORIZONTAL_PADDING,
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageMiniIcons.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageMiniIcons.tsx
deleted file mode 100644
index da609cc6a5..0000000000
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/TablePageMiniIcons.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import {
- IconBrandLinkedin,
- IconBuildingFactory2,
- IconCalendarEvent,
- IconCheck,
- IconChevronDown,
- IconCopy,
- IconCreativeCommonsSa,
- IconLink,
- IconMap2,
- IconMoneybag,
- IconPencil,
- IconPlus,
- IconTarget,
- IconTargetArrow,
- IconUser,
- IconUserCircle,
- IconUsers,
- IconX,
-} from '@tabler/icons-react';
-import type { ReactNode } from 'react';
-
-import {
- TABLE_PAGE_COLORS,
- TABLE_PAGE_TABLER_STROKE,
-} from './table-page-theme';
-
-type MiniIconProps = {
- color?: string;
- size?: number;
-};
-
-const HEADER_ICON_MAP: Record = {
- added: IconCalendarEvent,
- accountOwner: IconUserCircle,
- address: IconMap2,
- arr: IconMoneybag,
- createdBy: IconCreativeCommonsSa,
- employees: IconUsers,
- icp: IconTarget,
- industry: IconBuildingFactory2,
- linkedin: IconBrandLinkedin,
- mainContact: IconUser,
- opportunities: IconTargetArrow,
- url: IconLink,
-};
-
-export function PlusMini({
- color = TABLE_PAGE_COLORS.textSecondary,
- size = 14,
-}: MiniIconProps) {
- return (
-
- );
-}
-
-export function CheckMini({
- color = TABLE_PAGE_COLORS.text,
- size = 12,
-}: MiniIconProps) {
- return (
-
- );
-}
-
-export function CloseMini({
- color = TABLE_PAGE_COLORS.text,
- size = 12,
-}: MiniIconProps) {
- return (
-
- );
-}
-
-export function PencilMini({
- color = TABLE_PAGE_COLORS.textSecondary,
- size = 14,
-}: MiniIconProps) {
- return (
-
- );
-}
-
-export function CopyMini({
- color = TABLE_PAGE_COLORS.textSecondary,
- size = 14,
-}: MiniIconProps) {
- return (
-
- );
-}
-
-export function ChevronDownMini({
- color = TABLE_PAGE_COLORS.textTertiary,
- size = 14,
-}: MiniIconProps) {
- return (
-
- );
-}
-
-export function renderTableHeaderIcon(columnId: string): ReactNode {
- const Icon = HEADER_ICON_MAP[columnId] ?? IconCalendarEvent;
-
- return (
-
- );
-}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/render-table-header-icon.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/render-table-header-icon.tsx
new file mode 100644
index 0000000000..34da560093
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/render-table-header-icon.tsx
@@ -0,0 +1,48 @@
+import {
+ IconBrandLinkedin,
+ IconBuildingFactory2,
+ IconCalendarEvent,
+ IconCreativeCommonsSa,
+ IconLink,
+ IconMap2,
+ IconMoneybag,
+ IconTarget,
+ IconTargetArrow,
+ IconUser,
+ IconUserCircle,
+ IconUsers,
+} from '@tabler/icons-react';
+import type { ReactNode } from 'react';
+
+import {
+ TABLE_PAGE_COLORS,
+ TABLE_PAGE_TABLER_STROKE,
+} from './table-page-theme';
+
+const HEADER_ICON_MAP: Record = {
+ added: IconCalendarEvent,
+ accountOwner: IconUserCircle,
+ address: IconMap2,
+ arr: IconMoneybag,
+ createdBy: IconCreativeCommonsSa,
+ employees: IconUsers,
+ icp: IconTarget,
+ industry: IconBuildingFactory2,
+ linkedin: IconBrandLinkedin,
+ mainContact: IconUser,
+ opportunities: IconTargetArrow,
+ url: IconLink,
+};
+
+export function renderTableHeaderIcon(columnId: string): ReactNode {
+ const Icon = HEADER_ICON_MAP[columnId] ?? IconCalendarEvent;
+
+ return (
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/table-page-theme.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/table-page-theme.ts
index 2812f1005d..65d823df50 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/table-page-theme.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Table/table-page-theme.ts
@@ -1,4 +1,4 @@
-import { VISUAL_TOKENS } from '../../Shared/homeVisualTokens';
+import { VISUAL_TOKENS } from '../../Shared/utils/home-visual-tokens';
export const TABLE_PAGE_FONT = VISUAL_TOKENS.font.family;
export const TABLE_PAGE_CELL_HORIZONTAL_PADDING = 8;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/get-workflow-node-by-id.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/get-workflow-node-by-id.ts
new file mode 100644
index 0000000000..9c3fa7f9ea
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/get-workflow-node-by-id.ts
@@ -0,0 +1,14 @@
+import type { WorkflowNodeDefinition } from './workflow-page-data';
+
+export function getWorkflowNodeById(
+ nodes: ReadonlyArray,
+ nodeId: string,
+) {
+ const node = nodes.find((workflowNode) => workflowNode.id === nodeId);
+
+ if (!node) {
+ throw new Error(`Unknown workflow node: ${nodeId}`);
+ }
+
+ return node;
+}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.test.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.test.ts
index d1227a2b84..fa54ed64d1 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.test.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.test.ts
@@ -1,8 +1,6 @@
import type { WorkflowNodeDefinition } from './workflow-page-data';
-import {
- getWorkflowEdgePath,
- getWorkflowNodeById,
-} from './workflow-page-geometry';
+import { getWorkflowNodeById } from './get-workflow-node-by-id';
+import { getWorkflowEdgePath } from './workflow-page-geometry';
import { IconPlug } from '@tabler/icons-react';
const nodes: WorkflowNodeDefinition[] = [
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.ts
index c339095b97..3c78df7925 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-geometry.ts
@@ -3,25 +3,13 @@ import type {
WorkflowEdgeDefinition,
WorkflowNodeDefinition,
} from './workflow-page-data';
+import { getWorkflowNodeById } from './get-workflow-node-by-id';
type Point = {
x: number;
y: number;
};
-export function getWorkflowNodeById(
- nodes: ReadonlyArray,
- nodeId: string,
-) {
- const node = nodes.find((workflowNode) => workflowNode.id === nodeId);
-
- if (!node) {
- throw new Error(`Unknown workflow node: ${nodeId}`);
- }
-
- return node;
-}
-
function getNodeTopCenter(node: WorkflowNodeDefinition): Point {
return {
x: node.x + node.width / 2,
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-theme.ts b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-theme.ts
index 40d68ecb56..51b9d04884 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-theme.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Pages/Workflow/workflow-page-theme.ts
@@ -1,4 +1,4 @@
-import { VISUAL_TOKENS } from '../../Shared/homeVisualTokens';
+import { VISUAL_TOKENS } from '../../Shared/utils/home-visual-tokens';
export const WORKFLOW_PAGE_FONT = VISUAL_TOKENS.font.family;
export const WORKFLOW_PAGE_TABLER_STROKE = 1.6;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualAvatar.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualAvatar.tsx
new file mode 100644
index 0000000000..bc28626a44
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualAvatar.tsx
@@ -0,0 +1,68 @@
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+import type { ReactNode } from 'react';
+
+import { APP_FONT } from '../utils/home-visual-theme';
+
+const HOME_VISUAL_PERSON_TONES: Record<
+ string,
+ { background: string; color: string }
+> = {
+ amber: { background: '#f6e6d7', color: '#7a4f2a' },
+ blue: { background: '#dbeafe', color: '#1d4ed8' },
+ gray: { background: '#e5e7eb', color: '#4b5563' },
+ green: { background: '#dcfce7', color: '#15803d' },
+ orange: { background: '#ffdcc3', color: '#ED5F00' },
+ pink: { background: '#ffe4e6', color: '#be123c' },
+ purple: { background: '#ede9fe', color: '#6d28d9' },
+ red: { background: '#fee2e2', color: '#b91c1c' },
+ teal: { background: '#ccfbf1', color: '#0f766e' },
+};
+
+const AvatarFrame = styled.div<{
+ $background: string;
+ $color: string;
+ $size: number;
+ $square?: boolean;
+}>`
+ align-items: center;
+ background: ${({ $background }) => $background};
+ border-radius: ${({ $square }) => ($square ? '4px' : '999px')};
+ color: ${({ $color }) => $color};
+ display: flex;
+ flex: 0 0 auto;
+ font-family: ${APP_FONT};
+ font-size: 10px;
+ font-weight: ${theme.font.weight.medium};
+ height: ${({ $size }) => `${$size}px`};
+ justify-content: center;
+ line-height: 1;
+ overflow: hidden;
+ width: ${({ $size }) => `${$size}px`};
+`;
+
+export function HomeVisualAvatar({
+ children,
+ size = 14,
+ square = false,
+ tone = 'gray',
+}: {
+ children: ReactNode;
+ size?: number;
+ square?: boolean;
+ tone?: string;
+}) {
+ const resolvedTone =
+ HOME_VISUAL_PERSON_TONES[tone] ?? HOME_VISUAL_PERSON_TONES.gray;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/homeVisualChip.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualChip.tsx
similarity index 89%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/homeVisualChip.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualChip.tsx
index 58c73df227..d1f8645f97 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/homeVisualChip.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualChip.tsx
@@ -1,31 +1,9 @@
import { styled } from '@linaria/react';
-import {
- type KeyboardEvent,
- type MouseEvent,
- type PointerEvent,
- type ReactNode,
-} from 'react';
+import { type KeyboardEvent, type MouseEvent, type PointerEvent } from 'react';
-import { VISUAL_TOKENS } from './homeVisualTokens';
-
-export enum ChipVariant {
- Highlighted = 'highlighted',
- Regular = 'regular',
- Transparent = 'transparent',
- Rounded = 'rounded',
- Static = 'static',
-}
-
-export type ChipProps = {
- label: string;
- clickable?: boolean;
- isBold?: boolean;
- leftComponent?: ReactNode | null;
- className?: string;
- maxWidth?: number;
- onClick?: () => void;
- variant?: ChipVariant;
-};
+import type { ChipProps } from '../utils/chip-props';
+import { ChipVariant } from '../utils/chip-variant';
+import { VISUAL_TOKENS } from '../utils/home-visual-tokens';
const StyledContainer = styled.div<
Pick
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualFaviconLogo.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualFaviconLogo.tsx
new file mode 100644
index 0000000000..8d2960a971
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualFaviconLogo.tsx
@@ -0,0 +1,100 @@
+import { getSharedCompanyLogoUrlFromDomainName } from '@/content/site/asset-paths';
+import { createBoundedFailureCache } from '@/lib/visual-runtime';
+import { styled } from '@linaria/react';
+import { useState } from 'react';
+
+import { getHomeVisualInitials } from '../utils/get-home-visual-initials';
+import { APP_FONT } from '../utils/home-visual-theme';
+
+const failedFaviconUrls = createBoundedFailureCache(256);
+
+const FaviconFrame = styled.div<{ $size: number }>`
+ align-items: center;
+ border-radius: 4px;
+ display: flex;
+ flex: 0 0 auto;
+ font-family: ${APP_FONT};
+ font-size: ${({ $size }) => ($size <= 14 ? '8px' : '9px')};
+ font-weight: 600;
+ height: ${({ $size }) => `${$size}px`};
+ justify-content: center;
+ line-height: 1;
+ overflow: hidden;
+ width: ${({ $size }) => `${$size}px`};
+`;
+
+const FaviconFallbackFrame = styled(FaviconFrame)`
+ background: #ebebeb;
+ color: #666666;
+`;
+
+const FaviconImage = styled.img`
+ display: block;
+ height: 100%;
+ object-fit: contain;
+ width: 100%;
+`;
+
+function sanitizeURL(link: string | null | undefined) {
+ return link
+ ? link.replace(/(https?:\/\/)|(www\.)/g, '').replace(/\/$/, '')
+ : '';
+}
+
+function getHomeVisualLogoUrlFromDomainName(
+ domainName?: string,
+): string | undefined {
+ const sharedLogoUrl = getSharedCompanyLogoUrlFromDomainName(domainName);
+
+ if (sharedLogoUrl) {
+ return sharedLogoUrl;
+ }
+
+ const sanitizedDomain = sanitizeURL(domainName);
+
+ return sanitizedDomain
+ ? `https://twenty-icons.com/${sanitizedDomain}`
+ : undefined;
+}
+
+export function HomeVisualFaviconLogo({
+ domain,
+ label,
+ size = 14,
+ src,
+}: {
+ domain?: string;
+ label?: string;
+ size?: number;
+ src?: string;
+}) {
+ const faviconUrl = src ?? getHomeVisualLogoUrlFromDomainName(domain);
+ const [localFailedUrl, setLocalFailedUrl] = useState(null);
+ const showFavicon =
+ faviconUrl !== undefined &&
+ !failedFaviconUrls.has(faviconUrl) &&
+ localFailedUrl !== faviconUrl;
+
+ if (showFavicon) {
+ return (
+
+ {
+ failedFaviconUrls.add(faviconUrl);
+ setLocalFailedUrl(faviconUrl);
+ }}
+ />
+
+ );
+ }
+
+ const initials = label ? getHomeVisualInitials(label) : '?';
+
+ return (
+
+ {initials.slice(0, 1)}
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/home-visual-icon.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualIcon.tsx
similarity index 86%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/home-visual-icon.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualIcon.tsx
index 57af250d75..04a21aec16 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/home-visual-icon.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualIcon.tsx
@@ -21,13 +21,13 @@ import {
import type { ReactNode } from 'react';
import type { HeroSidebarIcon } from '@/sections/Hero/types';
-import { HomeVisualFaviconLogo } from './home-visual-identity';
+import { HomeVisualFaviconLogo } from './HomeVisualFaviconLogo';
import {
APP_FONT,
COLORS,
NAVIGATION_TABLER_STROKE,
TABLER_STROKE,
-} from './home-visual-theme';
+} from '../utils/home-visual-theme';
const SIDEBAR_TONES: Record<
string,
@@ -45,26 +45,6 @@ const SIDEBAR_TONES: Record<
violet: { background: '#ebe5ff', border: '#d8cbff', color: '#5b3fd1' },
};
-const hexToRgbTuple = (hex: string): string => {
- const clean = hex.replace('#', '');
- const expanded =
- clean.length === 3
- ? clean
- .split('')
- .map((char) => char + char)
- .join('')
- : clean;
- const value = parseInt(expanded, 16);
- return `${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}`;
-};
-
-const SIDEBAR_TONE_RGB: Record = Object.fromEntries(
- Object.entries(SIDEBAR_TONES).map(([tone, palette]) => [
- tone,
- hexToRgbTuple(palette.color),
- ]),
-);
-
const TABLER_ICON_MAP: Record = {
book: IconBook,
buildingSkyscraper: IconBuildingSkyscraper,
@@ -178,13 +158,6 @@ function LinkOverlay() {
);
}
-export function getSidebarIconToneRgb(icon: HeroSidebarIcon): string {
- const iconTone =
- 'tone' in icon && typeof icon.tone === 'string' ? icon.tone : 'gray';
-
- return SIDEBAR_TONE_RGB[iconTone] ?? SIDEBAR_TONE_RGB.gray;
-}
-
export function renderHomeVisualIcon(
icon: HeroSidebarIcon,
pulse: boolean = false,
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/HomeVisualLoader.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualLoader.tsx
similarity index 95%
rename from packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/HomeVisualLoader.tsx
rename to packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualLoader.tsx
index 84082e0f98..9bb59ab90c 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/HomeVisualLoader.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualLoader.tsx
@@ -1,6 +1,6 @@
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
-import { VISUAL_TOKENS } from './homeVisualTokens';
+import { VISUAL_TOKENS } from '../utils/home-visual-tokens';
const DEFAULT_LOADER_COLOR = `var(--tw-button-color, ${VISUAL_TOKENS.font.color.tertiary})`;
diff --git a/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualPersonAvatar.tsx b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualPersonAvatar.tsx
new file mode 100644
index 0000000000..210b2fb60b
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/HomeVisual/Shared/components/HomeVisualPersonAvatar.tsx
@@ -0,0 +1,59 @@
+import { createBoundedFailureCache } from '@/lib/visual-runtime';
+import { styled } from '@linaria/react';
+import { useState } from 'react';
+
+import type { HeroCellPerson } from '@/sections/Hero/types';
+
+import { getHomeVisualInitials } from '../utils/get-home-visual-initials';
+import { HomeVisualAvatar } from './HomeVisualAvatar';
+
+const failedAvatarUrls = createBoundedFailureCache(256);
+
+type HomeVisualPersonIdentity = Pick<
+ HeroCellPerson,
+ 'avatarUrl' | 'kind' | 'name' | 'shortLabel' | 'tone'
+>;
+
+const AvatarImage = styled.img`
+ display: block;
+ height: 100%;
+ object-fit: cover;
+ width: 100%;
+`;
+
+export function HomeVisualPersonAvatar({
+ person,
+ size = 14,
+}: {
+ person: HomeVisualPersonIdentity;
+ size?: number;
+}) {
+ const [localFailedUrl, setLocalFailedUrl] = useState(null);
+ const square =
+ person.kind === 'api' ||
+ person.kind === 'system' ||
+ person.kind === 'workflow';
+ const showAvatar =
+ person.avatarUrl !== undefined &&
+ !failedAvatarUrls.has(person.avatarUrl) &&
+ localFailedUrl !== person.avatarUrl;
+
+ return (
+