diff --git a/package.json b/package.json index a5e3ba4159..64833b624b 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "packages/twenty-utils", "packages/twenty-zapier", "packages/twenty-website", + "packages/twenty-website-redone", "packages/twenty-docs", "packages/twenty-e2e-testing", "packages/twenty-shared", diff --git a/packages/twenty-website-redone/.gitignore b/packages/twenty-website-redone/.gitignore new file mode 100644 index 0000000000..048c8bda80 --- /dev/null +++ b/packages/twenty-website-redone/.gitignore @@ -0,0 +1,11 @@ +/node_modules +/.next/ +/out/ +.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.env +.env* +*.tsbuildinfo +next-env.d.ts diff --git a/packages/twenty-website-redone/.oxlintrc.json b/packages/twenty-website-redone/.oxlintrc.json new file mode 100644 index 0000000000..766fbf702e --- /dev/null +++ b/packages/twenty-website-redone/.oxlintrc.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "ignorePatterns": [], + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "error" + }, + "plugins": ["react", "typescript", "oxc", "unicorn"], + "rules": { + "react/react-in-jsx-scope": "off", + "typescript/no-explicit-any": "error", + "typescript/consistent-type-definitions": ["error", "type"], + "no-unused-vars": "error" + } +} diff --git a/packages/twenty-website-redone/jest.config.mjs b/packages/twenty-website-redone/jest.config.mjs new file mode 100644 index 0000000000..467f651115 --- /dev/null +++ b/packages/twenty-website-redone/jest.config.mjs @@ -0,0 +1,30 @@ +const jestConfig = { + displayName: 'twenty-website-redone', + preset: '../../jest.preset.js', + testEnvironment: 'node', + // twenty-ui and twenty-shared ship ESM (.mjs) in their dist; transform them + // (everything else in node_modules stays ignored) so jest can load them. + transformIgnorePatterns: [ + '/node_modules/(?!(twenty-ui|twenty-shared)/.*)', + '../../node_modules/(?!(twenty-ui|twenty-shared)/.*)', + ], + transform: { + '^.+\\.(ts|js|tsx|jsx|mjs)$': [ + '@swc/jest', + { + jsc: { + parser: { syntax: 'typescript', tsx: true }, + transform: { react: { runtime: 'automatic' } }, + }, + }, + ], + }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '^@lingui/core/macro$': '/test/lingui-macro-mock.ts', + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs'], + coverageDirectory: './coverage', +}; + +export default jestConfig; diff --git a/packages/twenty-website-redone/lingui.config.ts b/packages/twenty-website-redone/lingui.config.ts new file mode 100644 index 0000000000..4ecb9a8c26 --- /dev/null +++ b/packages/twenty-website-redone/lingui.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from '@lingui/conf'; +import { formatter } from '@lingui/format-po'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; + +import { WEBSITE_LOCALE_LIST } from './src/platform/i18n/website-locale-list'; + +export default defineConfig({ + sourceLocale: SOURCE_LOCALE, + locales: [...WEBSITE_LOCALE_LIST], + fallbackLocales: { + default: SOURCE_LOCALE, + }, + catalogs: [ + { + path: '/src/locales/{locale}', + include: ['src'], + }, + ], + catalogsMergePath: '/src/locales/generated/{locale}', + compileNamespace: 'ts', + format: formatter({ lineNumbers: false, printLinguiId: true }), +}); diff --git a/packages/twenty-website-redone/next.config.ts b/packages/twenty-website-redone/next.config.ts new file mode 100644 index 0000000000..114bdc7b6c --- /dev/null +++ b/packages/twenty-website-redone/next.config.ts @@ -0,0 +1,50 @@ +import path from 'path'; +import withLinaria, { type LinariaConfig } from 'next-with-linaria'; + +import { localeToUrlSegment } from './src/platform/i18n/locale-to-url-segment'; +import { buildLocaleRewrites } from './src/platform/routing/locale-rewrite-patterns'; +import { WEBSITE_LOCALE_LIST } from './src/platform/i18n/website-locale-list'; + +// Bundler decision: Next 16 defaults dev and build to Turbopack, and +// next-with-linaria@1.3 branches on process.env.TURBOPACK to apply its +// Turbopack loader config — the same combination the original twenty-website +// already runs in dev. Webpack stays available behind `next dev --webpack` +// as the escape hatch if a wyw-in-js edge case surfaces. +const DEPLOYED_LOCALE_URL_SEGMENTS = + WEBSITE_LOCALE_LIST.map(localeToUrlSegment); + +const nextConfig: LinariaConfig = { + reactCompiler: true, + linaria: { + configFile: path.resolve(__dirname, 'wyw-in-js.config.cjs'), + }, + experimental: { + swcPlugins: [ + [ + '@lingui/swc-plugin', + { + runtimeModules: { + i18n: ['@lingui/core', 'i18n'], + trans: ['@lingui/react', 'Trans'], + }, + }, + ], + ], + }, + // Clean public URLs: the source locale is unprefixed, other locales get a + // short segment. Rewrites map unprefixed paths onto the internal /[locale] + // tree; redirects canonicalize away explicit source-locale prefixes. + async rewrites() { + return { + beforeFiles: buildLocaleRewrites(DEPLOYED_LOCALE_URL_SEGMENTS), + }; + }, + async redirects() { + return [ + { source: '/en', destination: '/', statusCode: 301 }, + { source: '/en/:path*', destination: '/:path*', statusCode: 301 }, + ]; + }, +}; + +export default withLinaria(nextConfig); diff --git a/packages/twenty-website-redone/package.json b/packages/twenty-website-redone/package.json new file mode 100644 index 0000000000..c632b51a04 --- /dev/null +++ b/packages/twenty-website-redone/package.json @@ -0,0 +1,42 @@ +{ + "name": "twenty-website-redone", + "private": true, + "scripts": { + "dev": "npx next dev --port 3004", + "build": "npx next build", + "start": "npx next start --port 3004" + }, + "dependencies": { + "@babel/runtime": "^7.27.6", + "@base-ui/react": "^1.3.0", + "@calcom/embed-react": "^1.5.3", + "@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", + "@tabler/icons-react": "^3.41.1", + "@wyw-in-js/babel-preset": "^0.8.1", + "@wyw-in-js/transform": "^0.8.1", + "next": "^16.2.6", + "next-with-linaria": "^1.3.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "server-only": "^0.0.1", + "stripe": "^20.3.1", + "three": "^0.184.0", + "twenty-shared": "workspace:*", + "twenty-ui": "workspace:*", + "zod": "^4.1.11" + }, + "devDependencies": { + "@lingui/cli": "^5.1.2", + "@lingui/conf": "5.1.2", + "@lingui/format-po": "5.1.2", + "@lingui/swc-plugin": "^5.11.0", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/three": "^0.184.1" + } +} diff --git a/packages/twenty-website-redone/project.json b/packages/twenty-website-redone/project.json new file mode 100644 index 0000000000..4d15ae39c2 --- /dev/null +++ b/packages/twenty-website-redone/project.json @@ -0,0 +1,71 @@ +{ + "name": "twenty-website-redone", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/twenty-website-redone/src", + "projectType": "application", + "tags": ["scope:website"], + "targets": { + "dev": { + "executor": "nx:run-commands", + "options": { + "cwd": "{projectRoot}", + "command": "npx next dev --port 3004" + } + }, + "build": { + "executor": "nx:run-commands", + "options": { + "cwd": "{projectRoot}", + "command": "npx next build" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "dependsOn": ["^build"], + "options": { + "cwd": "{projectRoot}", + "command": "npx tsc -p tsconfig.json --noEmit" + } + }, + "test": { + "executor": "nx:run-commands", + "dependsOn": ["^build"], + "options": { + "cwd": "{projectRoot}", + "command": "npx jest --config=jest.config.mjs" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "cwd": "{projectRoot}", + "command": "node scripts/check-conventions.mjs && node scripts/check-translations.mjs && npx oxlint -c .oxlintrc.json . && npx oxfmt --check ." + }, + "configurations": { + "fix": { + "command": "npx oxfmt ." + } + }, + "dependsOn": [ + { + "projects": ["twenty-ui"], + "target": "build" + } + ] + }, + "lingui:extract": { + "executor": "nx:run-commands", + "options": { + "cwd": "{projectRoot}", + "command": "lingui extract --overwrite --clean" + } + }, + "lingui:compile": { + "executor": "nx:run-commands", + "options": { + "cwd": "{projectRoot}", + "command": "lingui compile --typescript" + } + } + } +} diff --git a/packages/twenty-website-redone/public/images/customers/case-studies/act-education.webp b/packages/twenty-website-redone/public/images/customers/case-studies/act-education.webp new file mode 100644 index 0000000000..cc9544ea33 Binary files /dev/null and b/packages/twenty-website-redone/public/images/customers/case-studies/act-education.webp differ diff --git a/packages/twenty-website-redone/public/images/customers/case-studies/alternative-partners.webp b/packages/twenty-website-redone/public/images/customers/case-studies/alternative-partners.webp new file mode 100644 index 0000000000..f4bdce553a Binary files /dev/null and b/packages/twenty-website-redone/public/images/customers/case-studies/alternative-partners.webp differ diff --git a/packages/twenty-website-redone/public/images/customers/case-studies/authors/amrendra-singh.webp b/packages/twenty-website-redone/public/images/customers/case-studies/authors/amrendra-singh.webp new file mode 100644 index 0000000000..7c23601588 Binary files /dev/null and b/packages/twenty-website-redone/public/images/customers/case-studies/authors/amrendra-singh.webp differ diff --git a/packages/twenty-website-redone/public/images/customers/case-studies/authors/benjamin-reynolds.webp b/packages/twenty-website-redone/public/images/customers/case-studies/authors/benjamin-reynolds.webp new file mode 100644 index 0000000000..27a549dae2 Binary files /dev/null and b/packages/twenty-website-redone/public/images/customers/case-studies/authors/benjamin-reynolds.webp differ diff --git a/packages/twenty-website-redone/public/images/customers/case-studies/authors/joseph-chiang.webp b/packages/twenty-website-redone/public/images/customers/case-studies/authors/joseph-chiang.webp new file mode 100644 index 0000000000..28acfff234 Binary files /dev/null and b/packages/twenty-website-redone/public/images/customers/case-studies/authors/joseph-chiang.webp differ diff --git a/packages/twenty-website-redone/public/images/customers/case-studies/authors/mike-babiy.webp b/packages/twenty-website-redone/public/images/customers/case-studies/authors/mike-babiy.webp new file mode 100644 index 0000000000..1a9d1dd5c0 Binary files /dev/null and b/packages/twenty-website-redone/public/images/customers/case-studies/authors/mike-babiy.webp differ diff --git a/packages/twenty-website-redone/public/images/customers/case-studies/authors/olivier-reinaud.webp b/packages/twenty-website-redone/public/images/customers/case-studies/authors/olivier-reinaud.webp new file mode 100644 index 0000000000..51f3d1df5d Binary files /dev/null and b/packages/twenty-website-redone/public/images/customers/case-studies/authors/olivier-reinaud.webp differ diff --git a/packages/twenty-website-redone/public/images/customers/case-studies/elevate-consulting.webp b/packages/twenty-website-redone/public/images/customers/case-studies/elevate-consulting.webp new file mode 100644 index 0000000000..616b7e7ec2 Binary files /dev/null and b/packages/twenty-website-redone/public/images/customers/case-studies/elevate-consulting.webp differ diff --git a/packages/twenty-website-redone/public/images/customers/case-studies/netzero.webp b/packages/twenty-website-redone/public/images/customers/case-studies/netzero.webp new file mode 100644 index 0000000000..2b1c985a5f Binary files /dev/null and b/packages/twenty-website-redone/public/images/customers/case-studies/netzero.webp differ diff --git a/packages/twenty-website-redone/public/images/customers/case-studies/nine-dots.webp b/packages/twenty-website-redone/public/images/customers/case-studies/nine-dots.webp new file mode 100644 index 0000000000..f663a1aca1 Binary files /dev/null and b/packages/twenty-website-redone/public/images/customers/case-studies/nine-dots.webp differ diff --git a/packages/twenty-website-redone/public/images/customers/case-studies/w3villa.webp b/packages/twenty-website-redone/public/images/customers/case-studies/w3villa.webp new file mode 100644 index 0000000000..b8b280e27b Binary files /dev/null and b/packages/twenty-website-redone/public/images/customers/case-studies/w3villa.webp differ diff --git a/packages/twenty-website-redone/public/images/halftone/environment.jpg b/packages/twenty-website-redone/public/images/halftone/environment.jpg new file mode 100644 index 0000000000..0ddd056788 Binary files /dev/null and b/packages/twenty-website-redone/public/images/halftone/environment.jpg differ diff --git a/packages/twenty-website-redone/public/images/home/hero-bridge.webp b/packages/twenty-website-redone/public/images/home/hero-bridge.webp new file mode 100644 index 0000000000..c367f0d0c4 Binary files /dev/null and b/packages/twenty-website-redone/public/images/home/hero-bridge.webp differ diff --git a/packages/twenty-website-redone/public/images/home/problem/monolith-problem.webp b/packages/twenty-website-redone/public/images/home/problem/monolith-problem.webp new file mode 100644 index 0000000000..0207033279 Binary files /dev/null and b/packages/twenty-website-redone/public/images/home/problem/monolith-problem.webp differ diff --git a/packages/twenty-website-redone/public/images/home/stepper/download-worker.webp b/packages/twenty-website-redone/public/images/home/stepper/download-worker.webp new file mode 100644 index 0000000000..1a72b984b2 Binary files /dev/null and b/packages/twenty-website-redone/public/images/home/stepper/download-worker.webp differ diff --git a/packages/twenty-website-redone/public/images/home/three-cards-feature/familiar-interface-gradient.webp b/packages/twenty-website-redone/public/images/home/three-cards-feature/familiar-interface-gradient.webp new file mode 100644 index 0000000000..dd61846588 Binary files /dev/null and b/packages/twenty-website-redone/public/images/home/three-cards-feature/familiar-interface-gradient.webp differ diff --git a/packages/twenty-website-redone/public/images/home/three-cards-feature/fast-path-background-noise.webp b/packages/twenty-website-redone/public/images/home/three-cards-feature/fast-path-background-noise.webp new file mode 100644 index 0000000000..7a9340aa91 Binary files /dev/null and b/packages/twenty-website-redone/public/images/home/three-cards-feature/fast-path-background-noise.webp differ diff --git a/packages/twenty-website-redone/public/images/home/three-cards-feature/fast-path-gradient.webp b/packages/twenty-website-redone/public/images/home/three-cards-feature/fast-path-gradient.webp new file mode 100644 index 0000000000..4abb69a54c Binary files /dev/null and b/packages/twenty-website-redone/public/images/home/three-cards-feature/fast-path-gradient.webp differ diff --git a/packages/twenty-website-redone/public/images/home/three-cards-feature/live-data-gradient.webp b/packages/twenty-website-redone/public/images/home/three-cards-feature/live-data-gradient.webp new file mode 100644 index 0000000000..d80d1eb787 Binary files /dev/null and b/packages/twenty-website-redone/public/images/home/three-cards-feature/live-data-gradient.webp differ diff --git a/packages/twenty-website-redone/public/images/logo-bar/bayer.webp b/packages/twenty-website-redone/public/images/logo-bar/bayer.webp new file mode 100644 index 0000000000..55258e6542 Binary files /dev/null and b/packages/twenty-website-redone/public/images/logo-bar/bayer.webp differ diff --git a/packages/twenty-website-redone/public/images/logo-bar/civicactions.svg b/packages/twenty-website-redone/public/images/logo-bar/civicactions.svg new file mode 100644 index 0000000000..cd8a15d68f --- /dev/null +++ b/packages/twenty-website-redone/public/images/logo-bar/civicactions.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/twenty-website-redone/public/images/logo-bar/fora.svg b/packages/twenty-website-redone/public/images/logo-bar/fora.svg new file mode 100644 index 0000000000..6faf2d6a65 --- /dev/null +++ b/packages/twenty-website-redone/public/images/logo-bar/fora.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/packages/twenty-website-redone/public/images/logo-bar/french-republic.webp b/packages/twenty-website-redone/public/images/logo-bar/french-republic.webp new file mode 100644 index 0000000000..fade8f19fa Binary files /dev/null and b/packages/twenty-website-redone/public/images/logo-bar/french-republic.webp differ diff --git a/packages/twenty-website-redone/public/images/logo-bar/nic.webp b/packages/twenty-website-redone/public/images/logo-bar/nic.webp new file mode 100644 index 0000000000..a3ff37cc7c Binary files /dev/null and b/packages/twenty-website-redone/public/images/logo-bar/nic.webp differ diff --git a/packages/twenty-website-redone/public/images/logo-bar/otiima.svg b/packages/twenty-website-redone/public/images/logo-bar/otiima.svg new file mode 100644 index 0000000000..61d989ee8e --- /dev/null +++ b/packages/twenty-website-redone/public/images/logo-bar/otiima.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/twenty-website-redone/public/images/logo-bar/pwc.webp b/packages/twenty-website-redone/public/images/logo-bar/pwc.webp new file mode 100644 index 0000000000..3dff470e82 Binary files /dev/null and b/packages/twenty-website-redone/public/images/logo-bar/pwc.webp differ diff --git a/packages/twenty-website-redone/public/images/logo-bar/shiawase-home.webp b/packages/twenty-website-redone/public/images/logo-bar/shiawase-home.webp new file mode 100644 index 0000000000..2a75527727 Binary files /dev/null and b/packages/twenty-website-redone/public/images/logo-bar/shiawase-home.webp differ diff --git a/packages/twenty-website-redone/public/images/logo-bar/wazoku.svg b/packages/twenty-website-redone/public/images/logo-bar/wazoku.svg new file mode 100644 index 0000000000..b69fbb2332 --- /dev/null +++ b/packages/twenty-website-redone/public/images/logo-bar/wazoku.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/twenty-website-redone/public/images/logo-bar/windmill-logo.webp b/packages/twenty-website-redone/public/images/logo-bar/windmill-logo.webp new file mode 100644 index 0000000000..9aadf9a4ec Binary files /dev/null and b/packages/twenty-website-redone/public/images/logo-bar/windmill-logo.webp differ diff --git a/packages/twenty-website-redone/public/images/menu/developers.webp b/packages/twenty-website-redone/public/images/menu/developers.webp new file mode 100644 index 0000000000..b521308da6 Binary files /dev/null and b/packages/twenty-website-redone/public/images/menu/developers.webp differ diff --git a/packages/twenty-website-redone/public/images/menu/partners.webp b/packages/twenty-website-redone/public/images/menu/partners.webp new file mode 100644 index 0000000000..2b4aa73dd3 Binary files /dev/null and b/packages/twenty-website-redone/public/images/menu/partners.webp differ diff --git a/packages/twenty-website-redone/public/images/menu/user-guide.webp b/packages/twenty-website-redone/public/images/menu/user-guide.webp new file mode 100644 index 0000000000..98e171701c Binary files /dev/null and b/packages/twenty-website-redone/public/images/menu/user-guide.webp differ diff --git a/packages/twenty-website-redone/public/images/menu/why.webp b/packages/twenty-website-redone/public/images/menu/why.webp new file mode 100644 index 0000000000..58fda20d4e Binary files /dev/null and b/packages/twenty-website-redone/public/images/menu/why.webp differ diff --git a/packages/twenty-website-redone/public/images/og/default.png b/packages/twenty-website-redone/public/images/og/default.png new file mode 100644 index 0000000000..0f924eeb36 Binary files /dev/null and b/packages/twenty-website-redone/public/images/og/default.png differ diff --git a/packages/twenty-website-redone/public/images/partners/hero/partners-hero.webp b/packages/twenty-website-redone/public/images/partners/hero/partners-hero.webp new file mode 100644 index 0000000000..b6a63ba6c9 Binary files /dev/null and b/packages/twenty-website-redone/public/images/partners/hero/partners-hero.webp differ diff --git a/packages/twenty-website-redone/public/images/partners/promo/partner-meeting.webp b/packages/twenty-website-redone/public/images/partners/promo/partner-meeting.webp new file mode 100644 index 0000000000..b6a104c1b9 Binary files /dev/null and b/packages/twenty-website-redone/public/images/partners/promo/partner-meeting.webp differ diff --git a/packages/twenty-website-redone/public/images/partners/testimonials/benjamin-reynolds.webp b/packages/twenty-website-redone/public/images/partners/testimonials/benjamin-reynolds.webp new file mode 100644 index 0000000000..27a549dae2 Binary files /dev/null and b/packages/twenty-website-redone/public/images/partners/testimonials/benjamin-reynolds.webp differ diff --git a/packages/twenty-website-redone/public/images/partners/testimonials/bertrams.webp b/packages/twenty-website-redone/public/images/partners/testimonials/bertrams.webp new file mode 100644 index 0000000000..f71b5a741c Binary files /dev/null and b/packages/twenty-website-redone/public/images/partners/testimonials/bertrams.webp differ diff --git a/packages/twenty-website-redone/public/images/partners/testimonials/mike-babiy.webp b/packages/twenty-website-redone/public/images/partners/testimonials/mike-babiy.webp new file mode 100644 index 0000000000..1a9d1dd5c0 Binary files /dev/null and b/packages/twenty-website-redone/public/images/partners/testimonials/mike-babiy.webp differ diff --git a/packages/twenty-website-redone/public/images/pricing/engagement-band/halftone-on-white.webp b/packages/twenty-website-redone/public/images/pricing/engagement-band/halftone-on-white.webp new file mode 100644 index 0000000000..1cba624d75 Binary files /dev/null and b/packages/twenty-website-redone/public/images/pricing/engagement-band/halftone-on-white.webp differ diff --git a/packages/twenty-website-redone/public/images/pricing/plans/organization-icon.webp b/packages/twenty-website-redone/public/images/pricing/plans/organization-icon.webp new file mode 100644 index 0000000000..65f9a6f788 Binary files /dev/null and b/packages/twenty-website-redone/public/images/pricing/plans/organization-icon.webp differ diff --git a/packages/twenty-website-redone/public/images/pricing/plans/pro-icon.webp b/packages/twenty-website-redone/public/images/pricing/plans/pro-icon.webp new file mode 100644 index 0000000000..84b03da335 Binary files /dev/null and b/packages/twenty-website-redone/public/images/pricing/plans/pro-icon.webp differ diff --git a/packages/twenty-website-redone/public/images/pricing/salesfarce/help-icon.webp b/packages/twenty-website-redone/public/images/pricing/salesfarce/help-icon.webp new file mode 100644 index 0000000000..e17531138c Binary files /dev/null and b/packages/twenty-website-redone/public/images/pricing/salesfarce/help-icon.webp differ diff --git a/packages/twenty-website-redone/public/images/product/demo/background.webp b/packages/twenty-website-redone/public/images/product/demo/background.webp new file mode 100644 index 0000000000..30e570a1c9 Binary files /dev/null and b/packages/twenty-website-redone/public/images/product/demo/background.webp differ diff --git a/packages/twenty-website-redone/public/images/product/product-hero-background.webp b/packages/twenty-website-redone/public/images/product/product-hero-background.webp new file mode 100644 index 0000000000..b26530e714 Binary files /dev/null and b/packages/twenty-website-redone/public/images/product/product-hero-background.webp differ diff --git a/packages/twenty-website-redone/public/images/product/stepper/background-shape.webp b/packages/twenty-website-redone/public/images/product/stepper/background-shape.webp new file mode 100644 index 0000000000..aa4d04393a Binary files /dev/null and b/packages/twenty-website-redone/public/images/product/stepper/background-shape.webp differ diff --git a/packages/twenty-website-redone/public/images/product/stepper/background.webp b/packages/twenty-website-redone/public/images/product/stepper/background.webp new file mode 100644 index 0000000000..f2c4b16be9 Binary files /dev/null and b/packages/twenty-website-redone/public/images/product/stepper/background.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.10/0.10-currency.webp b/packages/twenty-website-redone/public/images/releases/0.10/0.10-currency.webp new file mode 100644 index 0000000000..f844ceefb9 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.10/0.10-currency.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.10/0.10-datetime.webp b/packages/twenty-website-redone/public/images/releases/0.10/0.10-datetime.webp new file mode 100644 index 0000000000..0d9a91d706 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.10/0.10-datetime.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.10/0.10-json.webp b/packages/twenty-website-redone/public/images/releases/0.10/0.10-json.webp new file mode 100644 index 0000000000..77b7b416e8 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.10/0.10-json.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.10/0.10-multi-select.webp b/packages/twenty-website-redone/public/images/releases/0.10/0.10-multi-select.webp new file mode 100644 index 0000000000..2f143f224b Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.10/0.10-multi-select.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.10/0.10-remote.webp b/packages/twenty-website-redone/public/images/releases/0.10/0.10-remote.webp new file mode 100644 index 0000000000..5ec62732fd Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.10/0.10-remote.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.11/0.11-calendar.webp b/packages/twenty-website-redone/public/images/releases/0.11/0.11-calendar.webp new file mode 100644 index 0000000000..e94ff9e3c9 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.11/0.11-calendar.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.11/0.11-speed.webp b/packages/twenty-website-redone/public/images/releases/0.11/0.11-speed.webp new file mode 100644 index 0000000000..ba50807898 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.11/0.11-speed.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.12/0.12-database-diagram.webp b/packages/twenty-website-redone/public/images/releases/0.12/0.12-database-diagram.webp new file mode 100644 index 0000000000..9e122faee4 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.12/0.12-database-diagram.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.12/0.12-link-field.webp b/packages/twenty-website-redone/public/images/releases/0.12/0.12-link-field.webp new file mode 100644 index 0000000000..17f9beb220 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.12/0.12-link-field.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.12/0.12-loader.webp b/packages/twenty-website-redone/public/images/releases/0.12/0.12-loader.webp new file mode 100644 index 0000000000..bf16273f51 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.12/0.12-loader.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.12/0.12-notifications.webp b/packages/twenty-website-redone/public/images/releases/0.12/0.12-notifications.webp new file mode 100644 index 0000000000..c9986e9c86 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.12/0.12-notifications.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.2.3_relations.webp b/packages/twenty-website-redone/public/images/releases/0.2.3_relations.webp new file mode 100644 index 0000000000..f0ed2e4fe5 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.2.3_relations.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.2.3_webhooks.webp b/packages/twenty-website-redone/public/images/releases/0.2.3_webhooks.webp new file mode 100644 index 0000000000..9573a4a007 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.2.3_webhooks.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.20/0.20-blocklist.webp b/packages/twenty-website-redone/public/images/releases/0.20/0.20-blocklist.webp new file mode 100644 index 0000000000..02f9400ae0 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.20/0.20-blocklist.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.20/0.20-onboarding.webp b/packages/twenty-website-redone/public/images/releases/0.20/0.20-onboarding.webp new file mode 100644 index 0000000000..43bc536e20 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.20/0.20-onboarding.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.20/0.20-timeline.webp b/packages/twenty-website-redone/public/images/releases/0.20/0.20-timeline.webp new file mode 100644 index 0000000000..95cd555e3a Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.20/0.20-timeline.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.21/0.21-advanced-email-settings.webp b/packages/twenty-website-redone/public/images/releases/0.21/0.21-advanced-email-settings.webp new file mode 100644 index 0000000000..c87e6eaf94 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.21/0.21-advanced-email-settings.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.21/0.21-many-many.webp b/packages/twenty-website-redone/public/images/releases/0.21/0.21-many-many.webp new file mode 100644 index 0000000000..7200508a47 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.21/0.21-many-many.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.22/0.22-kanban-improvements.webp b/packages/twenty-website-redone/public/images/releases/0.22/0.22-kanban-improvements.webp new file mode 100644 index 0000000000..5ab574d7ba Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.22/0.22-kanban-improvements.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.22/0.22-mass-deletion.webp b/packages/twenty-website-redone/public/images/releases/0.22/0.22-mass-deletion.webp new file mode 100644 index 0000000000..0923d79934 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.22/0.22-mass-deletion.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.22/0.22-navbar.webp b/packages/twenty-website-redone/public/images/releases/0.22/0.22-navbar.webp new file mode 100644 index 0000000000..45ff9b809d Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.22/0.22-navbar.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.23/0.23-created-by.webp b/packages/twenty-website-redone/public/images/releases/0.23/0.23-created-by.webp new file mode 100644 index 0000000000..0054b6dbaa Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.23/0.23-created-by.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.23/0.23-filter-webhooks.webp b/packages/twenty-website-redone/public/images/releases/0.23/0.23-filter-webhooks.webp new file mode 100644 index 0000000000..4b7327124e Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.23/0.23-filter-webhooks.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.23/0.23-notes-tasks.webp b/packages/twenty-website-redone/public/images/releases/0.23/0.23-notes-tasks.webp new file mode 100644 index 0000000000..d0df8d8e9a Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.23/0.23-notes-tasks.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.24/0.24-soft-delete.webp b/packages/twenty-website-redone/public/images/releases/0.24/0.24-soft-delete.webp new file mode 100644 index 0000000000..5c3087a3bb Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.24/0.24-soft-delete.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.3.0_rating.webp b/packages/twenty-website-redone/public/images/releases/0.3.0_rating.webp new file mode 100644 index 0000000000..2d7e610e1f Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.3.0_rating.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.3.1_contributors.webp b/packages/twenty-website-redone/public/images/releases/0.3.1_contributors.webp new file mode 100644 index 0000000000..fbf77fbdb5 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.3.1_contributors.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.3.2_new_layout.webp b/packages/twenty-website-redone/public/images/releases/0.3.2_new_layout.webp new file mode 100644 index 0000000000..865e6a2341 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.3.2_new_layout.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.3.3_emails.webp b/packages/twenty-website-redone/public/images/releases/0.3.3_emails.webp new file mode 100644 index 0000000000..4b4efe9eeb Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.3.3_emails.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.3.3_kanban.webp b/packages/twenty-website-redone/public/images/releases/0.3.3_kanban.webp new file mode 100644 index 0000000000..4b1de10ba1 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.3.3_kanban.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.3.3_sign_up.webp b/packages/twenty-website-redone/public/images/releases/0.3.3_sign_up.webp new file mode 100644 index 0000000000..b7a0b37793 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.3.3_sign_up.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.30/0.30-array-field.webp b/packages/twenty-website-redone/public/images/releases/0.30/0.30-array-field.webp new file mode 100644 index 0000000000..db9ad9d8b8 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.30/0.30-array-field.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.30/0.30-emails.webp b/packages/twenty-website-redone/public/images/releases/0.30/0.30-emails.webp new file mode 100644 index 0000000000..51fffd30b3 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.30/0.30-emails.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.30/0.30-new-settings.webp b/packages/twenty-website-redone/public/images/releases/0.30/0.30-new-settings.webp new file mode 100644 index 0000000000..4529459509 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.30/0.30-new-settings.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.31/0.31-advanced-settings.webp b/packages/twenty-website-redone/public/images/releases/0.31/0.31-advanced-settings.webp new file mode 100644 index 0000000000..d3b72ae731 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.31/0.31-advanced-settings.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.31/0.31-search.webp b/packages/twenty-website-redone/public/images/releases/0.31/0.31-search.webp new file mode 100644 index 0000000000..9bd451f23d Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.31/0.31-search.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.32/0.32-improved-cmdk.webp b/packages/twenty-website-redone/public/images/releases/0.32/0.32-improved-cmdk.webp new file mode 100644 index 0000000000..ada0f4dea3 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.32/0.32-improved-cmdk.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.32/0.32-webhooks.webp b/packages/twenty-website-redone/public/images/releases/0.32/0.32-webhooks.webp new file mode 100644 index 0000000000..9b7303c6dd Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.32/0.32-webhooks.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.33/0.33-multiselect-filter.webp b/packages/twenty-website-redone/public/images/releases/0.33/0.33-multiselect-filter.webp new file mode 100644 index 0000000000..0bb4bdaf06 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.33/0.33-multiselect-filter.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.33/0.33-percentage-number.webp b/packages/twenty-website-redone/public/images/releases/0.33/0.33-percentage-number.webp new file mode 100644 index 0000000000..a34fd338dd Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.33/0.33-percentage-number.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.34/0.34-subdomains.webp b/packages/twenty-website-redone/public/images/releases/0.34/0.34-subdomains.webp new file mode 100644 index 0000000000..85aa3602f8 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.34/0.34-subdomains.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.35/0.35-Favorites.webp b/packages/twenty-website-redone/public/images/releases/0.35/0.35-Favorites.webp new file mode 100644 index 0000000000..283626a5ed Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.35/0.35-Favorites.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.4/0.4-address-field-type.webp b/packages/twenty-website-redone/public/images/releases/0.4/0.4-address-field-type.webp new file mode 100644 index 0000000000..688268f252 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.4/0.4-address-field-type.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.4/0.4-expand-relation-card.webp b/packages/twenty-website-redone/public/images/releases/0.4/0.4-expand-relation-card.webp new file mode 100644 index 0000000000..34f831a842 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.4/0.4-expand-relation-card.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.4/0.4-multi-workspace.webp b/packages/twenty-website-redone/public/images/releases/0.4/0.4-multi-workspace.webp new file mode 100644 index 0000000000..c7f72a2706 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.4/0.4-multi-workspace.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.40/0.40-aggregates.webp b/packages/twenty-website-redone/public/images/releases/0.40/0.40-aggregates.webp new file mode 100644 index 0000000000..75b6accfd6 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.40/0.40-aggregates.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.40/0.40-group-by.webp b/packages/twenty-website-redone/public/images/releases/0.40/0.40-group-by.webp new file mode 100644 index 0000000000..6bad36ac83 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.40/0.40-group-by.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.41/0.41-labs.webp b/packages/twenty-website-redone/public/images/releases/0.41/0.41-labs.webp new file mode 100644 index 0000000000..3c2fc9d44f Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.41/0.41-labs.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.42/0.42-document-viewer.webp b/packages/twenty-website-redone/public/images/releases/0.42/0.42-document-viewer.webp new file mode 100644 index 0000000000..afc91bc33b Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.42/0.42-document-viewer.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.42/0.42-microsoft.webp b/packages/twenty-website-redone/public/images/releases/0.42/0.42-microsoft.webp new file mode 100644 index 0000000000..c328bac5b9 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.42/0.42-microsoft.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.42/0.42-translation.webp b/packages/twenty-website-redone/public/images/releases/0.42/0.42-translation.webp new file mode 100644 index 0000000000..0446f1d0b5 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.42/0.42-translation.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.43.0/email-privacy.webp b/packages/twenty-website-redone/public/images/releases/0.43.0/email-privacy.webp new file mode 100644 index 0000000000..28df356938 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.43.0/email-privacy.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.43.0/search-upgrade.webp b/packages/twenty-website-redone/public/images/releases/0.43.0/search-upgrade.webp new file mode 100644 index 0000000000..73efac4432 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.43.0/search-upgrade.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.44/0.44-admin-panel.webp b/packages/twenty-website-redone/public/images/releases/0.44/0.44-admin-panel.webp new file mode 100644 index 0000000000..fd964efb5d Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.44/0.44-admin-panel.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.44/0.44-side-panel.webp b/packages/twenty-website-redone/public/images/releases/0.44/0.44-side-panel.webp new file mode 100644 index 0000000000..8994228076 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.44/0.44-side-panel.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.50/0.50-advanced-filters.webp b/packages/twenty-website-redone/public/images/releases/0.50/0.50-advanced-filters.webp new file mode 100644 index 0000000000..67d622f2cb Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.50/0.50-advanced-filters.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.50/0.50-permissions.webp b/packages/twenty-website-redone/public/images/releases/0.50/0.50-permissions.webp new file mode 100644 index 0000000000..436c258837 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.50/0.50-permissions.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.51.0/0.51-options-menu.webp b/packages/twenty-website-redone/public/images/releases/0.51.0/0.51-options-menu.webp new file mode 100644 index 0000000000..85e240ad63 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.51.0/0.51-options-menu.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.52.0/0.52-custom-date-format.webp b/packages/twenty-website-redone/public/images/releases/0.52.0/0.52-custom-date-format.webp new file mode 100644 index 0000000000..0451626c76 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.52.0/0.52-custom-date-format.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/0.52.0/0.52-filtered-views-records.webp b/packages/twenty-website-redone/public/images/releases/0.52.0/0.52-filtered-views-records.webp new file mode 100644 index 0000000000..a4b76a3cab Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/0.52.0/0.52-filtered-views-records.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.00/1.00-import-update.webp b/packages/twenty-website-redone/public/images/releases/1.00/1.00-import-update.webp new file mode 100644 index 0000000000..7669c53ddb Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.00/1.00-import-update.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.00/1.00-performance-improvement.webp b/packages/twenty-website-redone/public/images/releases/1.00/1.00-performance-improvement.webp new file mode 100644 index 0000000000..6b632bb856 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.00/1.00-performance-improvement.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.00/1.00-permissions.webp b/packages/twenty-website-redone/public/images/releases/1.00/1.00-permissions.webp new file mode 100644 index 0000000000..a3e0f21c8c Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.00/1.00-permissions.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.00/1.00-subfield-filtering.webp b/packages/twenty-website-redone/public/images/releases/1.00/1.00-subfield-filtering.webp new file mode 100644 index 0000000000..0c4e33988c Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.00/1.00-subfield-filtering.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.00/1.00-workflow.webp b/packages/twenty-website-redone/public/images/releases/1.00/1.00-workflow.webp new file mode 100644 index 0000000000..7bec4bda48 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.00/1.00-workflow.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.1/1.1-multi-manual-trigger.webp b/packages/twenty-website-redone/public/images/releases/1.1/1.1-multi-manual-trigger.webp new file mode 100644 index 0000000000..4bf13fe68e Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.1/1.1-multi-manual-trigger.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.10/1.10.0-calendar.webp b/packages/twenty-website-redone/public/images/releases/1.10/1.10.0-calendar.webp new file mode 100644 index 0000000000..be7afd9863 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.10/1.10.0-calendar.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.10/1.10.0-dashboards.webp b/packages/twenty-website-redone/public/images/releases/1.10/1.10.0-dashboards.webp new file mode 100644 index 0000000000..caf52b8982 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.10/1.10.0-dashboards.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.11/1.11.0-morph-relations.webp b/packages/twenty-website-redone/public/images/releases/1.11/1.11.0-morph-relations.webp new file mode 100644 index 0000000000..4578f8275e Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.11/1.11.0-morph-relations.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.11/1.11.0-unlisted-views.webp b/packages/twenty-website-redone/public/images/releases/1.11/1.11.0-unlisted-views.webp new file mode 100644 index 0000000000..c3da1776b0 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.11/1.11.0-unlisted-views.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.12/1.12.0-folder-sync.webp b/packages/twenty-website-redone/public/images/releases/1.12/1.12.0-folder-sync.webp new file mode 100644 index 0000000000..db9270d7c5 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.12/1.12.0-folder-sync.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.12/1.12.0-side-panel.webp b/packages/twenty-website-redone/public/images/releases/1.12/1.12.0-side-panel.webp new file mode 100644 index 0000000000..d63c574a01 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.12/1.12.0-side-panel.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.13/1.13.0-stop-workflow-button.webp b/packages/twenty-website-redone/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-redone/public/images/releases/1.13/1.13.0-stop-workflow-button.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.webp b/packages/twenty-website-redone/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-redone/public/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.15/1.15.0-updated-by-official.webp b/packages/twenty-website-redone/public/images/releases/1.15/1.15.0-updated-by-official.webp new file mode 100644 index 0000000000..03cbf3f29e Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.15/1.15.0-updated-by-official.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.15/1.15.0-updated-by.webp b/packages/twenty-website-redone/public/images/releases/1.15/1.15.0-updated-by.webp new file mode 100644 index 0000000000..9233b6e28e Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.15/1.15.0-updated-by.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.16/1.16.0-files-in-records.webp b/packages/twenty-website-redone/public/images/releases/1.16/1.16.0-files-in-records.webp new file mode 100644 index 0000000000..468eca4078 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.16/1.16.0-files-in-records.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.16/1.16.0-flexible-relations.webp b/packages/twenty-website-redone/public/images/releases/1.16/1.16.0-flexible-relations.webp new file mode 100644 index 0000000000..20ed4b42d7 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.16/1.16.0-flexible-relations.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.17/1.17.0-ai-chat.webp b/packages/twenty-website-redone/public/images/releases/1.17/1.17.0-ai-chat.webp new file mode 100644 index 0000000000..d858aedd81 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.17/1.17.0-ai-chat.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.18/1.18.0-live-updates.webp b/packages/twenty-website-redone/public/images/releases/1.18/1.18.0-live-updates.webp new file mode 100644 index 0000000000..7fa22798f2 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.18/1.18.0-live-updates.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.18/1.18.0-sidebar-items.webp b/packages/twenty-website-redone/public/images/releases/1.18/1.18.0-sidebar-items.webp new file mode 100644 index 0000000000..9027c3cceb Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.18/1.18.0-sidebar-items.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.19/1.19.0-invite-roles.webp b/packages/twenty-website-redone/public/images/releases/1.19/1.19.0-invite-roles.webp new file mode 100644 index 0000000000..389ca32fdf Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.19/1.19.0-invite-roles.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.2/1.2-any-fields.webp b/packages/twenty-website-redone/public/images/releases/1.2/1.2-any-fields.webp new file mode 100644 index 0000000000..6d6ffaee2e Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.2/1.2-any-fields.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.2/1.2-import-relations.webp b/packages/twenty-website-redone/public/images/releases/1.2/1.2-import-relations.webp new file mode 100644 index 0000000000..686533f1da Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.2/1.2-import-relations.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.20/1.20.0-easier-field-editing.webp b/packages/twenty-website-redone/public/images/releases/1.20/1.20.0-easier-field-editing.webp new file mode 100644 index 0000000000..f237e7f40b Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.20/1.20.0-easier-field-editing.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.20/1.20.0-field-widgets.webp b/packages/twenty-website-redone/public/images/releases/1.20/1.20.0-field-widgets.webp new file mode 100644 index 0000000000..0d59358f32 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.20/1.20.0-field-widgets.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.21/1.21.0-email-replies.webp b/packages/twenty-website-redone/public/images/releases/1.21/1.21.0-email-replies.webp new file mode 100644 index 0000000000..a5c1fe257f Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.21/1.21.0-email-replies.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.21/1.21.0-maintenance-mode.webp b/packages/twenty-website-redone/public/images/releases/1.21/1.21.0-maintenance-mode.webp new file mode 100644 index 0000000000..3ff8ad2536 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.21/1.21.0-maintenance-mode.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.22/1.22.0-rich-text-layouts.webp b/packages/twenty-website-redone/public/images/releases/1.22/1.22.0-rich-text-layouts.webp new file mode 100644 index 0000000000..809c61a63e Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.22/1.22.0-rich-text-layouts.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.23/1.23.0-easier-layouts.webp b/packages/twenty-website-redone/public/images/releases/1.23/1.23.0-easier-layouts.webp new file mode 100644 index 0000000000..982a89fa49 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.23/1.23.0-easier-layouts.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.3/1.3-IMAP.webp b/packages/twenty-website-redone/public/images/releases/1.3/1.3-IMAP.webp new file mode 100644 index 0000000000..6263a77305 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.3/1.3-IMAP.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.3/1.3-merge.webp b/packages/twenty-website-redone/public/images/releases/1.3/1.3-merge.webp new file mode 100644 index 0000000000..529721c1ac Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.3/1.3-merge.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.4/1.4-field-permissions.webp b/packages/twenty-website-redone/public/images/releases/1.4/1.4-field-permissions.webp new file mode 100644 index 0000000000..75da4606a0 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.4/1.4-field-permissions.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.4/1.4-two-factor-auth.webp b/packages/twenty-website-redone/public/images/releases/1.4/1.4-two-factor-auth.webp new file mode 100644 index 0000000000..89c9704d65 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.4/1.4-two-factor-auth.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.4/1.4-workflow-filters.webp b/packages/twenty-website-redone/public/images/releases/1.4/1.4-workflow-filters.webp new file mode 100644 index 0000000000..ae4e2f4942 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.4/1.4-workflow-filters.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.5/1.5-workflow-branches.webp b/packages/twenty-website-redone/public/images/releases/1.5/1.5-workflow-branches.webp new file mode 100644 index 0000000000..f2e0b81446 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.5/1.5-workflow-branches.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.6/1.6-workflows-improvements.webp b/packages/twenty-website-redone/public/images/releases/1.6/1.6-workflows-improvements.webp new file mode 100644 index 0000000000..77a2d64d93 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.6/1.6-workflows-improvements.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.7/1.7-impersonating.webp b/packages/twenty-website-redone/public/images/releases/1.7/1.7-impersonating.webp new file mode 100644 index 0000000000..e6b3c23905 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.7/1.7-impersonating.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.7/1.7-upsert.webp b/packages/twenty-website-redone/public/images/releases/1.7/1.7-upsert.webp new file mode 100644 index 0000000000..7594e7eb6e Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.7/1.7-upsert.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.8/1.8-bulk-select.webp b/packages/twenty-website-redone/public/images/releases/1.8/1.8-bulk-select.webp new file mode 100644 index 0000000000..0d714fd21c Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.8/1.8-bulk-select.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.8/1.8-search-limit.webp b/packages/twenty-website-redone/public/images/releases/1.8/1.8-search-limit.webp new file mode 100644 index 0000000000..1d7359918e Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.8/1.8-search-limit.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/1.8/1.8-workflow-iterator.webp b/packages/twenty-website-redone/public/images/releases/1.8/1.8-workflow-iterator.webp new file mode 100644 index 0000000000..b2d64a112e Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/1.8/1.8-workflow-iterator.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-ai.webp b/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-ai.webp new file mode 100644 index 0000000000..4a32968445 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-ai.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-build-anything.webp b/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-build-anything.webp new file mode 100644 index 0000000000..106ad45b4d Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-build-anything.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-build-with-tools.webp b/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-build-with-tools.webp new file mode 100644 index 0000000000..8c53fe8494 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-build-with-tools.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-custom-layouts.webp b/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-custom-layouts.webp new file mode 100644 index 0000000000..1ee2dfdd0e Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-custom-layouts.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-version-control.webp b/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-version-control.webp new file mode 100644 index 0000000000..c67545d38e Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/2.0/2.0.0-version-control.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/labs/translation.webp b/packages/twenty-website-redone/public/images/releases/labs/translation.webp new file mode 100644 index 0000000000..4541612e43 Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/labs/translation.webp differ diff --git a/packages/twenty-website-redone/public/images/releases/milestone.webp b/packages/twenty-website-redone/public/images/releases/milestone.webp new file mode 100644 index 0000000000..d115be180b Binary files /dev/null and b/packages/twenty-website-redone/public/images/releases/milestone.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/accel.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/accel.webp new file mode 100644 index 0000000000..7e61057680 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/accel.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/airbnb.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/airbnb.webp new file mode 100644 index 0000000000..2c9fa7324f Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/airbnb.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/anthropic.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/anthropic.webp new file mode 100644 index 0000000000..9156a40778 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/anthropic.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/cursor.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/cursor.webp new file mode 100644 index 0000000000..a531ddf91c Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/cursor.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/figma.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/figma.webp new file mode 100644 index 0000000000..ede071c202 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/figma.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/github.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/github.webp new file mode 100644 index 0000000000..df3c08d959 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/github.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/google.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/google.webp new file mode 100644 index 0000000000..a56243b0bd Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/google.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/linear.svg b/packages/twenty-website-redone/public/images/shared/companies/logos/linear.svg new file mode 100644 index 0000000000..8147bdefa2 --- /dev/null +++ b/packages/twenty-website-redone/public/images/shared/companies/logos/linear.svg @@ -0,0 +1,7 @@ + + + + diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/linkedin.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/linkedin.webp new file mode 100644 index 0000000000..a9c994ce68 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/linkedin.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/mailchimp.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/mailchimp.webp new file mode 100644 index 0000000000..7bc14f1c10 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/mailchimp.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/notion.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/notion.webp new file mode 100644 index 0000000000..c4118c38a3 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/notion.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/sequoia.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/sequoia.webp new file mode 100644 index 0000000000..bb3f60aca1 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/sequoia.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/slack.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/slack.webp new file mode 100644 index 0000000000..68c4424b38 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/slack.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/stripe.webp b/packages/twenty-website-redone/public/images/shared/companies/logos/stripe.webp new file mode 100644 index 0000000000..a2a65a1045 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/companies/logos/stripe.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/companies/logos/twenty.svg b/packages/twenty-website-redone/public/images/shared/companies/logos/twenty.svg new file mode 100644 index 0000000000..4ad9f6dfdd --- /dev/null +++ b/packages/twenty-website-redone/public/images/shared/companies/logos/twenty.svg @@ -0,0 +1 @@ + diff --git a/packages/twenty-website-redone/public/images/shared/halftone/twenty-logo.svg b/packages/twenty-website-redone/public/images/shared/halftone/twenty-logo.svg new file mode 100644 index 0000000000..b3086be88e --- /dev/null +++ b/packages/twenty-website-redone/public/images/shared/halftone/twenty-logo.svg @@ -0,0 +1,13 @@ + + + + diff --git a/packages/twenty-website-redone/public/images/shared/light-noise.webp b/packages/twenty-website-redone/public/images/shared/light-noise.webp new file mode 100644 index 0000000000..566a5da51b Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/light-noise.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-felix.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-felix.webp new file mode 100644 index 0000000000..e8ce7605af Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-felix.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-indira.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-indira.webp new file mode 100644 index 0000000000..c351b70f86 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-indira.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-laura.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-laura.webp new file mode 100644 index 0000000000..edc9b1e221 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-laura.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-mike.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-mike.webp new file mode 100644 index 0000000000..0c38085fc0 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-mike.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-thomas.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-thomas.webp new file mode 100644 index 0000000000..79fc11a8d8 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/anonymous-thomas.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/ben-chestnut.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/ben-chestnut.webp new file mode 100644 index 0000000000..68985a00ab Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/ben-chestnut.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/brian-chesky.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/brian-chesky.webp new file mode 100644 index 0000000000..db95480be0 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/brian-chesky.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/chris-wanstrath.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/chris-wanstrath.webp new file mode 100644 index 0000000000..54b2de4f4f Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/chris-wanstrath.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/dario-amodei.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/dario-amodei.webp new file mode 100644 index 0000000000..fb708daf8b Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/dario-amodei.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/dylan-field.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/dylan-field.webp new file mode 100644 index 0000000000..dd6af85617 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/dylan-field.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/eddy-cue.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/eddy-cue.webp new file mode 100644 index 0000000000..486ce5a535 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/eddy-cue.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/ivan-zhao.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/ivan-zhao.webp new file mode 100644 index 0000000000..a27d6ad93e Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/ivan-zhao.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/jeff-williams.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/jeff-williams.webp new file mode 100644 index 0000000000..cf14da400b Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/jeff-williams.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/joe-gebbia.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/joe-gebbia.webp new file mode 100644 index 0000000000..f1de37b2bc Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/joe-gebbia.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/katherine-adams.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/katherine-adams.webp new file mode 100644 index 0000000000..e18ac3037e Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/katherine-adams.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/patrick-collison.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/patrick-collison.webp new file mode 100644 index 0000000000..1ad15e6b8b Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/patrick-collison.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/peter-reinhardt.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/peter-reinhardt.webp new file mode 100644 index 0000000000..5a2d4fa192 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/peter-reinhardt.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/peter-thiel.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/peter-thiel.webp new file mode 100644 index 0000000000..cddd929e0f Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/peter-thiel.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/ping-li.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/ping-li.webp new file mode 100644 index 0000000000..9eeb3d9592 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/ping-li.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/ray-damm.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/ray-damm.webp new file mode 100644 index 0000000000..dc65b2e1cd Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/ray-damm.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/reid-hoffman.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/reid-hoffman.webp new file mode 100644 index 0000000000..8ce8b887bf Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/reid-hoffman.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/roelof-botha.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/roelof-botha.webp new file mode 100644 index 0000000000..18c392f008 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/roelof-botha.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/ryan-roslansky.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/ryan-roslansky.webp new file mode 100644 index 0000000000..449311bae2 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/ryan-roslansky.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/stewart-butterfield.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/stewart-butterfield.webp new file mode 100644 index 0000000000..f35c93b849 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/stewart-butterfield.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/sundar-pichai.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/sundar-pichai.webp new file mode 100644 index 0000000000..4d852dc83b Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/sundar-pichai.webp differ diff --git a/packages/twenty-website-redone/public/images/shared/people/avatars/thomas-dohmke.webp b/packages/twenty-website-redone/public/images/shared/people/avatars/thomas-dohmke.webp new file mode 100644 index 0000000000..60b0b5b363 Binary files /dev/null and b/packages/twenty-website-redone/public/images/shared/people/avatars/thomas-dohmke.webp differ diff --git a/packages/twenty-website-redone/public/images/why-twenty/hero/background.webp b/packages/twenty-website-redone/public/images/why-twenty/hero/background.webp new file mode 100644 index 0000000000..58fda20d4e Binary files /dev/null and b/packages/twenty-website-redone/public/images/why-twenty/hero/background.webp differ diff --git a/packages/twenty-website-redone/public/lottie/stepper/stepper.lottie b/packages/twenty-website-redone/public/lottie/stepper/stepper.lottie new file mode 100644 index 0000000000..006eacb859 Binary files /dev/null and b/packages/twenty-website-redone/public/lottie/stepper/stepper.lottie differ diff --git a/packages/twenty-website-redone/public/models/diamond.glb b/packages/twenty-website-redone/public/models/diamond.glb new file mode 100644 index 0000000000..49351a601e Binary files /dev/null and b/packages/twenty-website-redone/public/models/diamond.glb differ diff --git a/packages/twenty-website-redone/public/models/eye.glb b/packages/twenty-website-redone/public/models/eye.glb new file mode 100644 index 0000000000..c759e62091 Binary files /dev/null and b/packages/twenty-website-redone/public/models/eye.glb differ diff --git a/packages/twenty-website-redone/public/models/faq.glb b/packages/twenty-website-redone/public/models/faq.glb new file mode 100644 index 0000000000..7a7a40d49b Binary files /dev/null and b/packages/twenty-website-redone/public/models/faq.glb differ diff --git a/packages/twenty-website-redone/public/models/flash.glb b/packages/twenty-website-redone/public/models/flash.glb new file mode 100644 index 0000000000..117c4ecbc6 Binary files /dev/null and b/packages/twenty-website-redone/public/models/flash.glb differ diff --git a/packages/twenty-website-redone/public/models/footer.glb b/packages/twenty-website-redone/public/models/footer.glb new file mode 100644 index 0000000000..cd99c34fc0 Binary files /dev/null and b/packages/twenty-website-redone/public/models/footer.glb differ diff --git a/packages/twenty-website-redone/public/models/hourglass.glb b/packages/twenty-website-redone/public/models/hourglass.glb new file mode 100644 index 0000000000..2a6616d5ed Binary files /dev/null and b/packages/twenty-website-redone/public/models/hourglass.glb differ diff --git a/packages/twenty-website-redone/public/models/lock.glb b/packages/twenty-website-redone/public/models/lock.glb new file mode 100644 index 0000000000..d21a1ca5a9 Binary files /dev/null and b/packages/twenty-website-redone/public/models/lock.glb differ diff --git a/packages/twenty-website-redone/public/models/money.glb b/packages/twenty-website-redone/public/models/money.glb new file mode 100644 index 0000000000..52e70ec0e2 Binary files /dev/null and b/packages/twenty-website-redone/public/models/money.glb differ diff --git a/packages/twenty-website-redone/public/models/quote.glb b/packages/twenty-website-redone/public/models/quote.glb new file mode 100644 index 0000000000..262adde0c7 Binary files /dev/null and b/packages/twenty-website-redone/public/models/quote.glb differ diff --git a/packages/twenty-website-redone/public/models/single-screen.glb b/packages/twenty-website-redone/public/models/single-screen.glb new file mode 100644 index 0000000000..216a706991 Binary files /dev/null and b/packages/twenty-website-redone/public/models/single-screen.glb differ diff --git a/packages/twenty-website-redone/public/models/spaceship.glb b/packages/twenty-website-redone/public/models/spaceship.glb new file mode 100644 index 0000000000..a829bbcc91 Binary files /dev/null and b/packages/twenty-website-redone/public/models/spaceship.glb differ diff --git a/packages/twenty-website-redone/public/models/speed.glb b/packages/twenty-website-redone/public/models/speed.glb new file mode 100644 index 0000000000..791d417537 Binary files /dev/null and b/packages/twenty-website-redone/public/models/speed.glb differ diff --git a/packages/twenty-website-redone/public/models/target.glb b/packages/twenty-website-redone/public/models/target.glb new file mode 100644 index 0000000000..4eaf1dccab Binary files /dev/null and b/packages/twenty-website-redone/public/models/target.glb differ diff --git a/packages/twenty-website-redone/public/models/why-twenty-hero.glb b/packages/twenty-website-redone/public/models/why-twenty-hero.glb new file mode 100644 index 0000000000..304b0db177 Binary files /dev/null and b/packages/twenty-website-redone/public/models/why-twenty-hero.glb differ diff --git a/packages/twenty-website-redone/scripts/background-cap.mjs b/packages/twenty-website-redone/scripts/background-cap.mjs new file mode 100644 index 0000000000..22225fef6a --- /dev/null +++ b/packages/twenty-website-redone/scripts/background-cap.mjs @@ -0,0 +1,103 @@ +import { readFileSync } from 'node:fs'; + +import { + createBattery, + launchBrowser, + NEW_BASE, + openPage, +} from './battery-kit.mjs'; + +// Decorative section backgrounds (gradients, halftones, notched cards, +// crosshairs) must never bleed past the content column — only the section's +// solid colour is full-bleed. The cap is read from the token itself, so this +// guard can never drift from it. Measured well past the cap so the centring is +// exercised: every [data-background-layer] must land <= the cap and centred. +const tokenSource = readFileSync( + new URL('../src/tokens/max-content-width.ts', import.meta.url), + 'utf8', +); +const MAX_CONTENT_WIDTH_PX = Number( + /MAX_CONTENT_WIDTH_PX\s*=\s*(\d+)/.exec(tokenSource)?.[1], +); +const WIDE_VIEWPORT = { height: 1200, width: 2400 }; +const PAGES = [ + '/', + '/product', + '/pricing', + '/customers', + '/partners', + '/why-twenty', + '/releases', +]; + +const battery = createBattery('background-cap'); +const browser = await launchBrowser(); + +for (const pagePath of PAGES) { + // eslint-disable-next-line no-await-in-loop + const page = await openPage(browser, `${NEW_BASE}${pagePath}`, { + settleMs: 1000, + viewport: WIDE_VIEWPORT, + }); + + // eslint-disable-next-line no-await-in-loop + const pageHeight = await page.evaluate( + () => document.documentElement.scrollHeight, + ); + for (let y = 0; y <= pageHeight; y += 800) { + // eslint-disable-next-line no-await-in-loop + await page.evaluate((scrollY) => window.scrollTo(0, scrollY), y); + // eslint-disable-next-line no-await-in-loop + await page.waitForTimeout(150); + } + + // eslint-disable-next-line no-await-in-loop + const layers = await page.evaluate(() => + [...document.querySelectorAll('[data-background-layer]')] + .map((element) => element.getBoundingClientRect()) + .filter((rect) => rect.width > 0) + .map((rect) => ({ + leftMargin: Math.round(rect.left), + rightMargin: Math.round(window.innerWidth - rect.right), + width: Math.round(rect.width), + })), + ); + // eslint-disable-next-line no-await-in-loop + await page.close(); + + if (layers.length === 0) { + battery.ok(`${pagePath} — no capped background layers rendered`, 'none'); + continue; + } + + const overflowing = layers.filter( + (layer) => layer.width > MAX_CONTENT_WIDTH_PX + 1, + ); + const offCentre = layers.filter( + (layer) => Math.abs(layer.leftMargin - layer.rightMargin) > 2, + ); + + if (overflowing.length > 0) { + battery.fail( + `${pagePath} background layers exceed ${MAX_CONTENT_WIDTH_PX}px`, + JSON.stringify(overflowing), + ); + } + if (offCentre.length > 0) { + battery.fail( + `${pagePath} background layers not centred`, + JSON.stringify(offCentre), + ); + } + if (overflowing.length === 0 && offCentre.length === 0) { + const widths = [...new Set(layers.map((layer) => layer.width))].toSorted( + (first, second) => second - first, + ); + battery.ok( + `${pagePath} — ${layers.length} background layers capped + centred`, + `widths ${widths.join(', ')}px <= ${MAX_CONTENT_WIDTH_PX}`, + ); + } +} + +await battery.finish(browser); diff --git a/packages/twenty-website-redone/scripts/battery-kit.mjs b/packages/twenty-website-redone/scripts/battery-kit.mjs new file mode 100644 index 0000000000..a78095eb4e --- /dev/null +++ b/packages/twenty-website-redone/scripts/battery-kit.mjs @@ -0,0 +1,63 @@ +import { chromium } from 'playwright'; + +// The shared chassis every parity battery runs on: one place for the +// pass/fail ledger, the A/B compare funnel and page setup. New batteries +// MUST build on this — and when the old site retires, compare() is the +// single seam where frozen fixtures replace live :3002 values. +export const OLD_BASE = process.env.MOCKUP_OLD_URL ?? 'http://localhost:3002'; +export const NEW_BASE = + process.env.VISUAL_BATTERY_URL ?? 'http://localhost:3004'; + +export const VIEWPORT = { width: 1440, height: 900 }; + +const ok = (label, detail) => + console.log(` ✓ ${label}${detail ? ` (${detail})` : ''}`); + +export function createBattery(name) { + const failures = []; + + const fail = (label, detail) => { + failures.push(`${label}: ${detail}`); + console.log(` ✗ ${label}: ${detail}`); + }; + + const compare = (label, oldValue, newValue) => { + const oldText = JSON.stringify(oldValue); + const newText = JSON.stringify(newValue); + if (oldText === newText) { + ok(label, oldText.length > 90 ? undefined : oldText); + } else { + fail(label, `old ${oldText} vs new ${newText}`); + } + }; + + const finish = async (browser) => { + await browser?.close(); + if (failures.length > 0) { + console.error(`${name}: FAILED (${failures.length})`); + process.exitCode = 1; + } else { + console.log(`${name}: OK`); + } + }; + + return { compare, fail, finish, ok }; +} + +export function launchBrowser() { + return chromium.launch({ channel: 'chrome', headless: true }); +} + +export async function openPage( + browser, + url, + { reducedMotion = false, settleMs = 800, viewport = VIEWPORT } = {}, +) { + const page = await browser.newPage({ + viewport: { width: viewport.width, height: viewport.height }, + reducedMotion: reducedMotion ? 'reduce' : 'no-preference', + }); + await page.goto(url, { waitUntil: 'load', timeout: 240000 }); + await page.waitForTimeout(settleMs); + return page; +} diff --git a/packages/twenty-website-redone/scripts/check-conventions.mjs b/packages/twenty-website-redone/scripts/check-conventions.mjs new file mode 100644 index 0000000000..d4cb9de2f1 --- /dev/null +++ b/packages/twenty-website-redone/scripts/check-conventions.mjs @@ -0,0 +1,407 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const sourceRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'src', +); + +// Next.js route files are framework contracts: they require default exports +// and may export route config alongside (metadata, generateStaticParams...). +const NEXT_CONTRACT_FILES = new Set([ + 'default.tsx', + 'error.tsx', + 'forbidden.tsx', + 'global-error.tsx', + 'layout.tsx', + 'loading.tsx', + 'manifest.ts', + 'not-found.tsx', + 'opengraph-image.tsx', + 'page.tsx', + 'robots.ts', + 'route.ts', + 'sitemap.ts', + 'template.tsx', + 'unauthorized.tsx', +]); + +const VALUE_EXPORT_PATTERN = + /^export (?:const|let|function|async function|class) /gm; +const DEFAULT_EXPORT_PATTERN = /^export default /m; +const REEXPORT_STATEMENT_PATTERN = + /export (?:type )?\{[\s\S]*?\} from '[^']+';|export \* from '[^']+';/g; + +const failures = []; + +// Locale rewrites run BEFORE the filesystem: every top-level public/ dir +// must be a reserved prefix or its assets 404 under /fr/* style rewrites. +// This bug class shipped three times (models, halftone, lottie) before +// this check existed. +{ + const patternsSource = fs.readFileSync( + 'src/platform/routing/locale-rewrite-patterns.ts', + 'utf8', + ); + const publicDirectories = fs + .readdirSync('public', { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + for (const directory of publicDirectories) { + if (!new RegExp(`'${directory}'`).test(patternsSource)) { + failures.push( + `public/${directory}/ is not in RESERVED_PREFIXES (locale-rewrite-patterns.ts) — its assets 404 under locale rewrites.`, + ); + } + } +} + +// Color and easing literals live only in src/tokens (comments stripped +// before matching). Authored one-offs are allowlisted with their reason. +const LITERAL_ALLOWLIST = new Set([]); +// Files allowed to set the new-tab security attributes themselves. +const EXTERNAL_LINK_OWNERS = new Set([ + 'src/ui/ExternalLink.tsx', + 'src/ui/Button.tsx', +]); + +// Owned vector glyphs are React components in src/icons — never .svg +// files in public/. Files here are third-party brand assets the site can +// only serve by URL (plus twenty.svg, the data layer's static export of +// src/icons/twenty-logo.tsx for the mockup's brand-image-by-URL path). +const PUBLIC_SVG_BRAND_FILES = new Set([ + 'public/images/logo-bar/otiima.svg', + 'public/images/logo-bar/civicactions.svg', + 'public/images/logo-bar/fora.svg', + 'public/images/logo-bar/wazoku.svg', + 'public/images/shared/companies/logos/linear.svg', + 'public/images/shared/companies/logos/twenty.svg', + // The halftone studio's default image input: fetched at runtime as an + // and fed through the halftone shader, not rendered as an icon glyph. + 'public/images/shared/halftone/twenty-logo.svg', +]); +// Vertical rhythm rides margins ('& > * + *'), not row-gap: gap breaks +// silently when a wrapper changes the child list. row-gap is allowed only +// where layout is genuinely multi-axis (wrapping rows, multi-column +// tracks) — listed here explicitly. +const ROW_GAP_MULTI_AXIS_FILES = new Set([ + 'sections/case-study-detail/CaseStudyHero.tsx', + 'sections/faq/Faq.tsx', + 'sections/faq/FaqItems.tsx', + 'sections/pricing-plans/PricingBoard.tsx', + 'sections/problem/Problem.tsx', + 'sections/releases-feed/ReleasesFeed.tsx', + 'sections/stepper/ProductStepper.tsx', + 'sections/stepper/Stepper.tsx', + 'sections/testimonials/PartnerTestimonialsCarousel.tsx', + 'sections/testimonials/TestimonialsCarousel.tsx', + 'sections/trusted-by/TrustedBy.tsx', + 'sections/why-twenty-editorial/Editorial.tsx', +]); + +const LITERAL_PATTERNS = [ + [/#[0-9a-fA-F]{3,8}\b/, 'hex color literal'], + [/rgba?\(/, 'rgb/rgba literal'], + [/cubic-bezier\(/, 'cubic-bezier literal'], +]; + +function walk(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + // oxfmt silently ignores directories named "lib" (build-output + // convention), so a lib/ directory would dodge formatting forever. + // src/locales/generated is the one sanctioned exception: it is the + // lingui compile output the shared CI regenerates, and oxfmt ignoring + // machine-generated catalogs is exactly what we want there. + const isLocalesGenerated = + entry.name === 'generated' && path.basename(directory) === 'locales'; + if ( + entry.name === 'lib' || + (entry.name === 'generated' && !isLocalesGenerated) + ) { + failures.push( + `${fullPath}: directories named "lib" or "generated" are forbidden (oxfmt ignores them).`, + ); + } + walk(fullPath); + continue; + } + + if (!/\.(ts|tsx)$/.test(entry.name)) continue; + + const content = fs.readFileSync(fullPath, 'utf8'); + const relativePath = path.relative(sourceRoot, fullPath); + const posixPath = relativePath.split(path.sep).join('/'); + + if ( + (posixPath.startsWith('ui/') || posixPath.startsWith('sections/')) && + /row-gap:/.test(content) && + !ROW_GAP_MULTI_AXIS_FILES.has(posixPath) + ) { + failures.push( + `src/${relativePath}: row-gap in a flow stack — use '& > * + * { margin-top: … }' (mind: unlike gap, the margin shifts absolutely-positioned non-first children; allowlist the file if the layout is genuinely multi-axis).`, + ); + } + + // The global * reset (layout.tsx) already zeroes every element's margin, + // so a component's own 'margin: 0' is redundant — and it ties with the + // owl rhythm ('& > * + * { margin-top }', equal specificity), silently + // collapsing the gap by source order (this broke a heading once). Cancel + // an owl gap deliberately with the specific 'margin-top: 0' instead. + if ( + posixPath !== 'app/[locale]/layout.tsx' && + // The /halftone generator bakes standalone HTML whose own '* { margin: 0 }' + // reset is required — the downloaded file has no global reset to inherit. + !posixPath.startsWith('platform/visuals/halftone-studio/') && + !relativePath.includes('.test.') && + /^[ \t]*margin:[ \t]*0;[ \t]*$/m.test(content) + ) { + failures.push( + `src/${relativePath}: redundant 'margin: 0' — the global * reset zeroes margins and it ties with the owl rhythm; remove it (use 'margin-top: 0' to deliberately cancel an owl gap).`, + ); + } + + if ( + !relativePath.startsWith('tokens' + path.sep) && + // The /halftone generator is a standalone color/shader tool: hex + rgba + // colors and cubic-bezier eases are its domain values (and what it + // exports), not design-system tokens. + !posixPath.startsWith('platform/visuals/halftone-studio/') && + !relativePath.includes('.test.') && + !LITERAL_ALLOWLIST.has(`src/${relativePath}`) + ) { + const withoutComments = content + .split('\n') + .map((line) => line.replace(/\/\/.*$/, '')) + .join('\n') + .replace(/\/\*[\s\S]*?\*\//g, ''); + for (const [pattern, label] of LITERAL_PATTERNS) { + if (pattern.test(withoutComments)) { + failures.push( + `src/${relativePath}: ${label} outside src/tokens — use a token.`, + ); + } + } + } + + // Breakpoints exist only through mediaUp(); a raw width query bypasses + // the breakpoint tokens (reduced-motion and print queries are fine). + if ( + !relativePath.startsWith('tokens' + path.sep) && + /@media \((?:min|max)-width/.test(content) + ) { + failures.push( + `src/${relativePath}: raw width @media query — use mediaUp().`, + ); + } + + if ( + !EXTERNAL_LINK_OWNERS.has(`src/${relativePath}`) && + /target="_blank"|noopener/.test(content) + ) { + failures.push( + `src/${relativePath}: new-tab attributes belong to ui/ExternalLink — compose it.`, + ); + } + + // Screen-reader strings are user-facing: a11y attributes must be + // localized, never string literals. + if ( + (relativePath.startsWith('sections' + path.sep) || + relativePath.startsWith('case-studies' + path.sep) || + relativePath.startsWith('app-preview' + path.sep) || + relativePath.startsWith('contact-cal' + path.sep) || + relativePath.startsWith('partner-application' + path.sep) || + relativePath.startsWith('partners-marketplace' + path.sep) || + relativePath.startsWith('pricing-state' + path.sep)) && + /(?:aria-label|ariaLabel|aria-roledescription|placeholder|alt)="[A-Za-z]/.test( + content, + ) + ) { + failures.push( + `src/${relativePath}: untranslated a11y string literal — wrap in i18n._(msg\`...\`).`, + ); + } + + // Sections are islands: importing another section couples compositions + // that must evolve independently. Shared shapes live in ui/icons/platform. + if (relativePath.startsWith('sections' + path.sep)) { + const ownSection = relativePath.split(path.sep)[1]; + const crossImport = [...content.matchAll(/from '@\/sections\/([a-z-]+)/g)] + .map((m) => m[1]) + .find((section) => section !== ownSection); + if (crossImport) { + failures.push( + `src/${relativePath}: imports from sections/${crossImport} — sections may not import each other.`, + ); + } + } + + // Shared composite layers (the product mockup, the contact modal) sit + // between sections and primitives: multiple sections consume them, so + // they may reach only the pure and platform layers, never sections. + const sharedLayer = [ + 'app-preview', + 'case-studies', + 'contact-cal', + 'partner-application', + 'partners-marketplace', + 'pricing-state', + ].find((layer) => relativePath.startsWith(layer + path.sep)); + if (sharedLayer) { + const allowedLayers = new Set([ + 'tokens', + 'icons', + 'ui', + 'platform', + sharedLayer, + ]); + const forbiddenLayer = [...content.matchAll(/from '@\/([a-z-]+)/g)] + .map((m) => m[1]) + .find((layer) => !allowedLayers.has(layer)); + if (forbiddenLayer) { + failures.push( + `src/${relativePath}: ${sharedLayer} may import only tokens/icons/ui/platform, found @/${forbiddenLayer}.`, + ); + } + } + + // twenty-ui's theme is pure data, baked by Linaria at build time — consume + // it directly so the mockups can't drift from the product. Its components + // are React runtime (+ react-tooltip): importing them would weigh down the + // marketing bundle, so the mockups stay on lean primitives built against + // the theme. + const badTwentyUiSubpath = [ + ...content.matchAll(/from 'twenty-ui(\/[a-z-]+)?'/g), + ] + .map((match) => match[1] ?? '') + .find( + (subpath) => subpath !== '/theme' && subpath !== '/theme-constants', + ); + if (badTwentyUiSubpath !== undefined) { + failures.push( + `src/${relativePath}: only twenty-ui/theme is importable (pure data, baked at build); twenty-ui${badTwentyUiSubpath} pulls React runtime into the bundle — build a lean primitive instead.`, + ); + } + + // three is heavy (~150KB gz): only the visuals heavy zones may value- + // import it, reached exclusively via the rigs' dynamic imports — the + // bundle boundary as a build invariant. (halftone-studio is the standalone + // /halftone generator tool, dynamic-imported on its own code-split route.) + if ( + !/^platform\/visuals\/(three-runtime|halftone|halftone-studio)\//.test( + relativePath.split(path.sep).join('/'), + ) && + /^import (?!type )[^;]*from 'three/m.test(content) + ) { + failures.push( + `src/${relativePath}: value-imports three outside platform/visuals heavy zones (use "import type" for types).`, + ); + } + + // tokens and icons are pure: no client runtime. + if ( + (relativePath.startsWith('tokens' + path.sep) || + relativePath.startsWith('icons' + path.sep)) && + content.includes("'use client'") + ) { + failures.push(`src/${relativePath}: 'use client' in a pure layer.`); + } + + // .tsx files are PascalCase (named after their React component); .ts + // files are kebab-case. Next.js route files (page/layout/...) and the + // compiled locale catalogs are exempt. + if ( + !NEXT_CONTRACT_FILES.has(entry.name) && + !relativePath.startsWith('locales' + path.sep) + ) { + if (entry.name.endsWith('.tsx')) { + if (!/^[A-Z][A-Za-z0-9]*\.tsx$/.test(entry.name)) { + failures.push(`src/${relativePath}: .tsx filenames are PascalCase.`); + } + } else if (/[A-Z]/.test(entry.name)) { + failures.push(`src/${relativePath}: .ts filenames are kebab-case.`); + } + } + + // SectionShell is the only owner of
: it is where vertical + // rhythm and surface schemes live, so no other file may create one. + if ( + relativePath !== path.join('ui', 'SectionShell.tsx') && + /]|styled\.section/.test(content) + ) { + failures.push( + `src/${relativePath}:
may only be rendered by ui/SectionShell.tsx.`, + ); + } + const isNextContractFile = + relativePath.startsWith('app' + path.sep) && + NEXT_CONTRACT_FILES.has(entry.name); + + if (isNextContractFile) continue; + + // The module-shape rules are line-anchored and must not read inside + // template literals (mock source-code fiction contains export lines). + const withoutTemplateLiterals = content.replace(/`[\s\S]*?`/g, '``'); + + if (DEFAULT_EXPORT_PATTERN.test(withoutTemplateLiterals)) { + failures.push( + `src/${relativePath}: default export outside a Next.js route file (use named exports).`, + ); + } + + if (entry.name === 'index.ts') { + const withoutReexports = content + .replace(REEXPORT_STATEMENT_PATTERN, '') + .replace(/\/\/[^\n]*/g, ''); + if (/\S/.test(withoutReexports)) { + failures.push( + `src/${relativePath}: barrels may only re-export (found: ${withoutReexports.trim().split('\n')[0]}).`, + ); + } + continue; + } + + const valueExportCount = ( + withoutTemplateLiterals.match(VALUE_EXPORT_PATTERN) ?? [] + ).length; + if (valueExportCount > 1) { + failures.push( + `src/${relativePath}: ${valueExportCount} value exports (limit is one per file).`, + ); + } + } +} + +walk(sourceRoot); + +// Public SVG audit: any .svg outside the brand-file allowlist means an +// owned glyph leaked out of src/icons. +const publicSvgs = []; +const walkPublic = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) walkPublic(fullPath); + else if (entry.name.endsWith('.svg')) publicSvgs.push(fullPath); + } +}; +walkPublic('public'); +for (const svgPath of publicSvgs) { + if (!PUBLIC_SVG_BRAND_FILES.has(svgPath)) { + failures.push( + `${svgPath}: owned vector glyphs are components in src/icons — public/ svg files are third-party brand assets only (or add to PUBLIC_SVG_BRAND_FILES with a reason).`, + ); + } +} + +if (failures.length > 0) { + console.error('check-conventions: FAILED'); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} + +console.log('check-conventions: OK'); diff --git a/packages/twenty-website-redone/scripts/check-translations.mjs b/packages/twenty-website-redone/scripts/check-translations.mjs new file mode 100644 index 0000000000..3821dfdfd7 --- /dev/null +++ b/packages/twenty-website-redone/scripts/check-translations.mjs @@ -0,0 +1,65 @@ +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// Translations are authored by the Crowdin automation in CI, so empty +// msgstr entries are a normal intermediate state and are NOT checked here. +// What IS the engineer's responsibility: every msg in code must be +// extracted into en.po, where Crowdin can see it. This re-runs extraction +// against a copy and fails when the catalog would change (unextracted new +// strings, or stale entries that --clean would remove). +const packageRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const localesDirectory = path.join(packageRoot, 'src', 'locales'); + +const readMsgIds = (file) => + new Set( + [...fs.readFileSync(file, 'utf8').matchAll(/^msgid "(.+)"$/gm)].map( + (match) => match[1], + ), + ); + +const catalogFiles = fs + .readdirSync(localesDirectory) + .filter((name) => name.endsWith('.po')); +const backups = new Map( + catalogFiles.map((name) => [ + name, + fs.readFileSync(path.join(localesDirectory, name), 'utf8'), + ]), +); +const before = readMsgIds(path.join(localesDirectory, 'en.po')); + +try { + execSync('npx lingui extract --overwrite --clean', { + cwd: packageRoot, + stdio: 'pipe', + }); + const after = readMsgIds(path.join(localesDirectory, 'en.po')); + + const unextracted = [...after].filter((id) => !before.has(id)); + const stale = [...before].filter((id) => !after.has(id)); + + if (unextracted.length > 0 || stale.length > 0) { + console.error( + 'check-translations: FAILED — run `lingui extract` and commit the catalogs', + ); + for (const id of unextracted) { + console.error(` not extracted: "${id.slice(0, 70)}"`); + } + for (const id of stale) { + console.error(` stale entry: "${id.slice(0, 70)}"`); + } + process.exitCode = 1; + } else { + console.log('check-translations: OK (catalogs in sync with source)'); + } +} finally { + // The check must never mutate the working tree. + for (const [name, content] of backups) { + fs.writeFileSync(path.join(localesDirectory, name), content); + } +} diff --git a/packages/twenty-website-redone/scripts/check-visual-bundle.mjs b/packages/twenty-website-redone/scripts/check-visual-bundle.mjs new file mode 100644 index 0000000000..59130c3a41 --- /dev/null +++ b/packages/twenty-website-redone/scripts/check-visual-bundle.mjs @@ -0,0 +1,48 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// The three chunk must never enter the initial bundle: rigs reach the heavy +// zones only through next/dynamic. This reads the production build manifest +// and fails if any initial chunk contains the three marker. +const packageRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const manifestPath = path.join(packageRoot, '.next', 'build-manifest.json'); + +if (!fs.existsSync(manifestPath)) { + console.error( + 'check-visual-bundle: run `next build` first (.next/build-manifest.json missing).', + ); + process.exit(1); +} + +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +const initialChunks = new Set([ + ...(manifest.rootMainFiles ?? []), + ...Object.values(manifest.pages ?? {}).flat(), +]); + +const offending = []; +for (const chunk of initialChunks) { + if (!chunk.endsWith('.js')) continue; + const chunkPath = path.join(packageRoot, '.next', chunk); + if (!fs.existsSync(chunkPath)) continue; + const content = fs.readFileSync(chunkPath, 'utf8'); + if (content.includes('WebGLRenderer') || content.includes('three.module')) { + offending.push(chunk); + } +} + +if (offending.length > 0) { + console.error( + 'check-visual-bundle: FAILED — three reached the initial bundle:', + ); + for (const chunk of offending) console.error(` ${chunk}`); + process.exit(1); +} + +console.log( + `check-visual-bundle: OK (${initialChunks.size} initial chunks, three absent)`, +); diff --git a/packages/twenty-website-redone/scripts/lcp-report.mjs b/packages/twenty-website-redone/scripts/lcp-report.mjs new file mode 100644 index 0000000000..fca9622bce --- /dev/null +++ b/packages/twenty-website-redone/scripts/lcp-report.mjs @@ -0,0 +1,71 @@ +import { chromium } from 'playwright'; + +// Same-run LCP comparison: both sites measured back to back under the +// same throttle (Fast-3G-ish network, 4x CPU), 3 cold loads each, median +// reported. A report, not a gate — run at wave close. +const OLD_URL = process.env.MOCKUP_OLD_URL ?? 'http://localhost:3002/'; +const NEW_URL = process.env.VISUAL_BATTERY_URL ?? 'http://localhost:3004/'; +const RUNS = 3; + +const browser = await chromium.launch({ channel: 'chrome', headless: true }); + +async function measureLcp(url) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 1, + }); + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + await cdp.send('Network.emulateNetworkConditions', { + offline: false, + latency: 150, + downloadThroughput: (1.6 * 1024 * 1024) / 8, + uploadThroughput: (750 * 1024) / 8, + }); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + + await page.addInitScript(() => { + window.__lcp = 0; + new PerformanceObserver((entryList) => { + for (const entry of entryList.getEntries()) { + window.__lcp = entry.startTime; + } + }).observe({ type: 'largest-contentful-paint', buffered: true }); + }); + + await page.goto(url, { waitUntil: 'load', timeout: 240000 }); + await page.waitForTimeout(3000); + const lcp = await page.evaluate(() => window.__lcp); + await context.close(); + return lcp; +} + +const median = (values) => + values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]; + +const report = {}; +for (const [label, url] of [ + ['old (:3002)', OLD_URL], + ['new (:3004)', NEW_URL], +]) { + const samples = []; + for (let run = 0; run < RUNS; run += 1) { + // eslint-disable-next-line no-await-in-loop + samples.push(await measureLcp(url)); + } + report[label] = { samples, median: median(samples) }; + console.log( + ` ${label}: median ${Math.round(median(samples))}ms (${samples + .map((sample) => Math.round(sample)) + .join(', ')})`, + ); +} + +await browser.close(); + +const oldMedian = report['old (:3002)'].median; +const newMedian = report['new (:3004)'].median; +const delta = ((newMedian - oldMedian) / oldMedian) * 100; +console.log( + `lcp-report: new is ${delta <= 0 ? '' : '+'}${delta.toFixed(1)}% vs old`, +); diff --git a/packages/twenty-website-redone/scripts/locks/home.json b/packages/twenty-website-redone/scripts/locks/home.json new file mode 100644 index 0000000000..3be77acc4a --- /dev/null +++ b/packages/twenty-website-redone/scripts/locks/home.json @@ -0,0 +1,882 @@ +{ + "base-390": { + "sections": [ + { + "scheme": "muted", + "rhythm": "hero", + "paddingTop": "30px", + "paddingBottom": "30px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "muted", + "rhythm": "section", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "muted", + "rhythm": "flush", + "paddingTop": "0px", + "paddingBottom": "0px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "muted", + "rhythm": "spacious", + "paddingTop": "120px", + "paddingBottom": "120px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": true + }, + { + "scheme": "dark", + "rhythm": "spacious", + "paddingTop": "120px", + "paddingBottom": "120px", + "background": "rgb(28, 28, 28)", + "followsSameScheme": false + } + ], + "eyebrows": [ + { + "label": "The Problem.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Stop settling for trade-offs.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "In production.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "They are the real sales", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Any Questions?", + "gapToHeading": 24, + "centered": true + } + ], + "headings": [ + { + "tag": "H1", + "text": "Build your Enterprise CRM at AI Speed", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "A custom CRM gives your org an edge, but building one comes ", + "fontSize": "40.0005px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Assemble, iterate and adapt a robust CRM, that's quick to fl", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Make your GTM team happy with a CRM they'll love", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Dev teams power company-wide change with Twenty", + "fontSize": "37.05px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "The flexibility is really what made the difference. Our need", + "fontSize": "40.0005px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "We didn't want to patch over the problem. We wanted to build", + "fontSize": "40.0005px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "It is just such a nicer experience than dealing with a Sales", + "fontSize": "40.0005px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Stop fighting custom. Start building, with Twenty", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "start" + } + ], + "visualSlots": [ + "diamond", + "familiar-interface", + "faq", + "fast-path", + "flash", + "footer-backdrop", + "hero-bridge", + "hourglass", + "live-data", + "lock", + "money", + "monolith", + "spaceship", + "stepper-backdrop", + "target" + ], + "ctas": [ + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "", + "href": "/customers/w3villa" + }, + { + "label": "", + "href": "/customers/alternative-partners" + }, + { + "label": "", + "href": "/customers/act-education" + }, + { + "label": "Read the case", + "href": "/customers/w3villa" + }, + { + "label": "Read the case", + "href": "/customers/act-education" + }, + { + "label": "Read the case", + "href": "/customers/netzero" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + } + ], + "sectionCount": 9 + }, + "sm-820": { + "sections": [ + { + "scheme": "muted", + "rhythm": "hero", + "paddingTop": "30px", + "paddingBottom": "30px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "muted", + "rhythm": "section", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "muted", + "rhythm": "flush", + "paddingTop": "0px", + "paddingBottom": "0px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "muted", + "rhythm": "spacious", + "paddingTop": "120px", + "paddingBottom": "120px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": true + }, + { + "scheme": "dark", + "rhythm": "spacious", + "paddingTop": "120px", + "paddingBottom": "120px", + "background": "rgb(28, 28, 28)", + "followsSameScheme": false + } + ], + "eyebrows": [ + { + "label": "The Problem.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Stop settling for trade-offs.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "In production.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "They are the real sales", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Any Questions?", + "gapToHeading": 24, + "centered": true + } + ], + "headings": [ + { + "tag": "H1", + "text": "Build your Enterprise CRM at AI Speed", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "A custom CRM gives your org an edge, but building one comes ", + "fontSize": "46.4789px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Assemble, iterate and adapt a robust CRM, that's quick to fl", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Make your GTM team happy with a CRM they'll love", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Dev teams power company-wide change with Twenty", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "The flexibility is really what made the difference. Our need", + "fontSize": "46.4789px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "We didn't want to patch over the problem. We wanted to build", + "fontSize": "46.4789px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "It is just such a nicer experience than dealing with a Sales", + "fontSize": "46.4789px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Stop fighting custom. Start building, with Twenty", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "start" + } + ], + "visualSlots": [ + "diamond", + "familiar-interface", + "faq", + "fast-path", + "flash", + "footer-backdrop", + "hero-bridge", + "hourglass", + "live-data", + "lock", + "money", + "monolith", + "spaceship", + "stepper-backdrop", + "target" + ], + "ctas": [ + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "", + "href": "/customers/w3villa" + }, + { + "label": "", + "href": "/customers/alternative-partners" + }, + { + "label": "", + "href": "/customers/act-education" + }, + { + "label": "Read the case", + "href": "/customers/w3villa" + }, + { + "label": "Read the case", + "href": "/customers/act-education" + }, + { + "label": "Read the case", + "href": "/customers/netzero" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + } + ], + "sectionCount": 9 + }, + "md-1100": { + "sections": [ + { + "scheme": "muted", + "rhythm": "hero", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "64px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "muted", + "rhythm": "section", + "paddingTop": "64px", + "paddingBottom": "64px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "64px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "muted", + "rhythm": "flush", + "paddingTop": "0px", + "paddingBottom": "0px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "muted", + "rhythm": "spacious", + "paddingTop": "120px", + "paddingBottom": "120px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": true + }, + { + "scheme": "dark", + "rhythm": "spacious", + "paddingTop": "120px", + "paddingBottom": "120px", + "background": "rgb(28, 28, 28)", + "followsSameScheme": false + } + ], + "eyebrows": [ + { + "label": "The Problem.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Stop settling for trade-offs.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "In production.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "They are the real sales", + "gapToHeading": 56, + "centered": true + }, + { + "label": "Any Questions?", + "gapToHeading": 24, + "centered": true + } + ], + "headings": [ + { + "tag": "H1", + "text": "Build your Enterprise CRM at AI Speed", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "A custom CRM gives your org an edge, but building one comes ", + "fontSize": "48px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Assemble, iterate and adapt a robust CRM, that's quick to fl", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Make your GTM team happy with a CRM they'll love", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "Dev teams power company-wide change with Twenty", + "fontSize": "80px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "The flexibility is really what made the difference. Our need", + "fontSize": "48px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "We didn't want to patch over the problem. We wanted to build", + "fontSize": "48px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "It is just such a nicer experience than dealing with a Sales", + "fontSize": "48px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Stop fighting custom. Start building, with Twenty", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "start" + } + ], + "visualSlots": [ + "diamond", + "familiar-interface", + "faq", + "fast-path", + "flash", + "footer-backdrop", + "hero-bridge", + "hourglass", + "live-data", + "lock", + "money", + "monolith", + "spaceship", + "stepper-backdrop", + "target" + ], + "ctas": [ + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "", + "href": "/customers/w3villa" + }, + { + "label": "", + "href": "/customers/alternative-partners" + }, + { + "label": "", + "href": "/customers/act-education" + }, + { + "label": "Read the case", + "href": "/customers/w3villa" + }, + { + "label": "Read the case", + "href": "/customers/act-education" + }, + { + "label": "Read the case", + "href": "/customers/netzero" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + } + ], + "sectionCount": 9 + }, + "lg-1440": { + "sections": [ + { + "scheme": "muted", + "rhythm": "hero", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "64px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "muted", + "rhythm": "section", + "paddingTop": "64px", + "paddingBottom": "64px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "64px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "muted", + "rhythm": "flush", + "paddingTop": "0px", + "paddingBottom": "0px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "muted", + "rhythm": "spacious", + "paddingTop": "120px", + "paddingBottom": "120px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": true + }, + { + "scheme": "dark", + "rhythm": "spacious", + "paddingTop": "120px", + "paddingBottom": "120px", + "background": "rgb(28, 28, 28)", + "followsSameScheme": false + } + ], + "eyebrows": [ + { + "label": "The Problem.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Stop settling for trade-offs.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "In production.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "They are the real sales", + "gapToHeading": 56, + "centered": true + }, + { + "label": "Any Questions?", + "gapToHeading": 24, + "centered": true + } + ], + "headings": [ + { + "tag": "H1", + "text": "Build your Enterprise CRM at AI Speed", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "A custom CRM gives your org an edge, but building one comes ", + "fontSize": "48px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Assemble, iterate and adapt a robust CRM, that's quick to fl", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Make your GTM team happy with a CRM they'll love", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "Dev teams power company-wide change with Twenty", + "fontSize": "80px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "The flexibility is really what made the difference. Our need", + "fontSize": "48px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "We didn't want to patch over the problem. We wanted to build", + "fontSize": "48px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "It is just such a nicer experience than dealing with a Sales", + "fontSize": "48px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Stop fighting custom. Start building, with Twenty", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "start" + } + ], + "visualSlots": [ + "diamond", + "familiar-interface", + "faq", + "fast-path", + "flash", + "footer-backdrop", + "hero-bridge", + "hourglass", + "live-data", + "lock", + "money", + "monolith", + "spaceship", + "stepper-backdrop", + "target" + ], + "ctas": [ + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "", + "href": "/customers/w3villa" + }, + { + "label": "", + "href": "/customers/alternative-partners" + }, + { + "label": "", + "href": "/customers/act-education" + }, + { + "label": "Read the case", + "href": "/customers/w3villa" + }, + { + "label": "Read the case", + "href": "/customers/act-education" + }, + { + "label": "Read the case", + "href": "/customers/netzero" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + } + ], + "sectionCount": 9 + } +} diff --git a/packages/twenty-website-redone/scripts/locks/product.json b/packages/twenty-website-redone/scripts/locks/product.json new file mode 100644 index 0000000000..37aa9e738f --- /dev/null +++ b/packages/twenty-website-redone/scripts/locks/product.json @@ -0,0 +1,730 @@ +{ + "base-390": { + "sections": [ + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "muted", + "rhythm": "section", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "dark", + "rhythm": "spacious", + "paddingTop": "120px", + "paddingBottom": "120px", + "background": "rgb(28, 28, 28)", + "followsSameScheme": false + } + ], + "eyebrows": [ + { + "label": "Core Features", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Stop settling for trade-offs.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Customization", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Try it live", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Any Questions?", + "gapToHeading": 24, + "centered": true + } + ], + "headings": [ + { + "tag": "H1", + "text": "A CRM for teams that move fast", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "...with AI that actually helps you work faster", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H1", + "text": "A CRM for teams that move fast", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "...with AI that actually helps you work faster", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "A CRM for teams that move fast", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "...with AI that actually helps you work faster", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "Everything you need, out of the box", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "A modern CRM with an intuitive interface", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Go the extra mile with no-code", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "A demo worth a thousand words", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "Stop fighting custom. Start building, with Twenty", + "fontSize": "40px", + "fontWeight": "300", + "textAlign": "start" + } + ], + "visualSlots": ["eye", "faq", "footer-backdrop", "singleScreen", "speed"], + "ctas": [ + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Try Twenty Cloud", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + } + ], + "sectionCount": 6 + }, + "sm-820": { + "sections": [ + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "muted", + "rhythm": "section", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "48px", + "paddingBottom": "48px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "dark", + "rhythm": "spacious", + "paddingTop": "120px", + "paddingBottom": "120px", + "background": "rgb(28, 28, 28)", + "followsSameScheme": false + } + ], + "eyebrows": [ + { + "label": "Core Features", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Stop settling for trade-offs.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Customization", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Try it live", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Any Questions?", + "gapToHeading": 24, + "centered": true + } + ], + "headings": [ + { + "tag": "H1", + "text": "A CRM for teams that move fast", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "...with AI that actually helps you work faster", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H1", + "text": "A CRM for teams that move fast", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "...with AI that actually helps you work faster", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "A CRM for teams that move fast", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "...with AI that actually helps you work faster", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "Everything you need, out of the box", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "A modern CRM with an intuitive interface", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Go the extra mile with no-code", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "A demo worth a thousand words", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "Stop fighting custom. Start building, with Twenty", + "fontSize": "56.1957px", + "fontWeight": "300", + "textAlign": "start" + } + ], + "visualSlots": ["eye", "faq", "footer-backdrop", "singleScreen", "speed"], + "ctas": [ + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Try Twenty Cloud", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + } + ], + "sectionCount": 6 + }, + "md-1100": { + "sections": [ + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "64px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "muted", + "rhythm": "section", + "paddingTop": "64px", + "paddingBottom": "64px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "64px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "dark", + "rhythm": "spacious", + "paddingTop": "120px", + "paddingBottom": "120px", + "background": "rgb(28, 28, 28)", + "followsSameScheme": false + } + ], + "eyebrows": [ + { + "label": "Core Features", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Stop settling for trade-offs.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Customization", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Try it live", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Any Questions?", + "gapToHeading": 24, + "centered": true + } + ], + "headings": [ + { + "tag": "H1", + "text": "A CRM for teams that move fast", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "...with AI that actually helps you work faster", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H1", + "text": "A CRM for teams that move fast", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "...with AI that actually helps you work faster", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "A CRM for teams that move fast", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "...with AI that actually helps you work faster", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "Everything you need, out of the box", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "A modern CRM with an intuitive interface", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Go the extra mile with no-code", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "A demo worth a thousand words", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "Stop fighting custom. Start building, with Twenty", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "start" + } + ], + "visualSlots": ["eye", "faq", "footer-backdrop", "singleScreen", "speed"], + "ctas": [ + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Try Twenty Cloud", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + } + ], + "sectionCount": 6 + }, + "lg-1440": { + "sections": [ + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "64px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "6px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": true + }, + { + "scheme": "muted", + "rhythm": "section", + "paddingTop": "64px", + "paddingBottom": "64px", + "background": "rgb(244, 244, 244)", + "followsSameScheme": false + }, + { + "scheme": "light", + "rhythm": "section", + "paddingTop": "64px", + "paddingBottom": "64px", + "background": "rgb(255, 255, 255)", + "followsSameScheme": false + }, + { + "scheme": "dark", + "rhythm": "spacious", + "paddingTop": "120px", + "paddingBottom": "120px", + "background": "rgb(28, 28, 28)", + "followsSameScheme": false + } + ], + "eyebrows": [ + { + "label": "Core Features", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Stop settling for trade-offs.", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Customization", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Try it live", + "gapToHeading": 24, + "centered": true + }, + { + "label": "Any Questions?", + "gapToHeading": 24, + "centered": true + } + ], + "headings": [ + { + "tag": "H1", + "text": "A CRM for teams that move fast", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "...with AI that actually helps you work faster", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H1", + "text": "A CRM for teams that move fast", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "...with AI that actually helps you work faster", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "A CRM for teams that move fast", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "...with AI that actually helps you work faster", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "Everything you need, out of the box", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "A modern CRM with an intuitive interface", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "Go the extra mile with no-code", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "start" + }, + { + "tag": "H2", + "text": "A demo worth a thousand words", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "center" + }, + { + "tag": "H2", + "text": "Stop fighting custom. Start building, with Twenty", + "fontSize": "60px", + "fontWeight": "300", + "textAlign": "start" + } + ], + "visualSlots": ["eye", "faq", "footer-backdrop", "singleScreen", "speed"], + "ctas": [ + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Try Twenty Cloud", + "href": "https://app.twenty.com/welcome" + }, + { + "label": "Get started", + "href": "https://app.twenty.com/welcome" + } + ], + "sectionCount": 6 + } +} diff --git a/packages/twenty-website-redone/scripts/mockup-old-parity.mjs b/packages/twenty-website-redone/scripts/mockup-old-parity.mjs new file mode 100644 index 0000000000..6b09c12c67 --- /dev/null +++ b/packages/twenty-website-redone/scripts/mockup-old-parity.mjs @@ -0,0 +1,193 @@ +import { chromium } from 'playwright'; + +// OLD-parity battery: computed values measured identically on :3002 and +// :3004 with selectors that resolve on BOTH DOMs. Exact equality except +// the executable ledger below. +const OLD_URL = process.env.MOCKUP_OLD_URL ?? 'http://localhost:3002/'; +const NEW_URL = process.env.VISUAL_BATTERY_URL ?? 'http://localhost:3004/'; + +// Every deliberate divergence is declared here; an unledgered diff fails, +// a ledgered-but-absent diff also fails. +// (Empty today: the one candidate — product transparent.medium p3 0.078 +// vs old rgba 0.08 — sits below colorimetric resolution and passes as +// equal.) Shape: [{ key, reason }]. +const LEDGER = []; + +const failures = []; +const assert = (condition, message) => { + console.log(` ${condition ? '✓' : '✗'} ${message}`); + if (!condition) { + failures.push(message); + } +}; + +async function measure(browser, url) { + const page = await browser.newPage({ + viewport: { width: 1440, height: 950 }, + deviceScaleFactor: 1, + }); + await page.goto(url, { waitUntil: 'networkidle', timeout: 240000 }); + await page.waitForTimeout(4000); + const values = await page.evaluate(() => { + // page.evaluate serializes this callback: helpers must live inside it. + // eslint-disable-next-line unicorn/consistent-function-scoping + const styleOf = (el) => (el ? getComputedStyle(el) : null); + // page.evaluate serializes this callback: helpers must live inside it. + // eslint-disable-next-line unicorn/consistent-function-scoping + const round = (n) => Math.round(n * 10) / 10; + // The framed window: both sites render a 20px-radius white shell. + const frame = [...document.querySelectorAll('div')].find((el) => { + const cs = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return cs.borderRadius === '20px' && rect.width > 900; + }); + const sidebar = frame?.querySelector('aside'); + const companiesLabel = [...(sidebar?.querySelectorAll('span') ?? [])].find( + (el) => el.textContent === 'Companies', + ); + const anthropicRow = frame?.querySelector('[data-row-id="anthropic"]'); + const firstCell = anthropicRow?.firstElementChild; + const chip = [...(anthropicRow?.querySelectorAll('div') ?? [])].find( + (el) => el.textContent?.trim() === 'Anthropic', + ); + const workspaceName = [...(sidebar?.querySelectorAll('span') ?? [])].find( + (el) => el.textContent === 'Apple', + ); + const newButton = [...(frame?.querySelectorAll('div, span') ?? [])].find( + (el) => + el.textContent?.trim() === 'New' && + styleOf(el.parentElement)?.borderRadius === '4px', + ); + const terminal = document.querySelector('[data-terminal-shell]'); + const terminalRect = terminal?.getBoundingClientRect(); + const terminalStyle = styleOf(terminal); + const sendButton = terminal?.querySelector( + 'button[aria-label="Send message"]', + ); + const workspaceChip = [ + ...(terminal?.querySelectorAll('button') ?? []), + ].find((el) => el.textContent?.includes('my-twenty-app')); + const promptText = [...(terminal?.querySelectorAll('p') ?? [])].find((el) => + el.textContent?.startsWith('Scaffold a launch-ops CRM'), + ); + const toggle = terminal?.querySelector('[role="tablist"]'); + const activeSegment = toggle?.querySelector('[aria-selected="true"]'); + const frameStyle = styleOf(frame); + const frameRect = frame?.getBoundingClientRect(); + return { + frameWidth: frameRect ? round(frameRect.width) : null, + frameRadius: frameStyle?.borderRadius ?? null, + frameBorder: frameStyle?.borderTopWidth ?? null, + sidebarWidth: sidebar + ? round(sidebar.getBoundingClientRect().width) + : null, + navLabelFont: styleOf(companiesLabel)?.fontSize ?? null, + navLabelWeight: styleOf(companiesLabel)?.fontWeight ?? null, + navLabelColor: styleOf(companiesLabel)?.color ?? null, + rowHeight: firstCell + ? round(firstCell.getBoundingClientRect().height) + : null, + cellBorderBottom: styleOf(firstCell)?.borderBottomColor ?? null, + chipHeight: chip ? round(chip.getBoundingClientRect().height) : null, + chipRadius: styleOf(chip)?.borderRadius ?? null, + chipBackground: styleOf(chip)?.backgroundColor ?? null, + workspaceNameFont: styleOf(workspaceName)?.fontSize ?? null, + workspaceNameColor: styleOf(workspaceName)?.color ?? null, + navActionBorderColor: newButton + ? styleOf(newButton.parentElement)?.borderTopColor + : null, + terminalWidth: terminalRect ? round(terminalRect.width) : null, + terminalHeight: terminalRect ? round(terminalRect.height) : null, + terminalRadius: terminalStyle?.borderRadius ?? null, + terminalBackground: terminalStyle?.backgroundColor ?? null, + terminalBorderColor: terminalStyle?.borderTopColor ?? null, + sendBackground: styleOf(sendButton)?.backgroundColor ?? null, + sendRadius: styleOf(sendButton)?.borderRadius ?? null, + workspaceChipBackground: styleOf(workspaceChip)?.backgroundColor ?? null, + workspaceChipColor: styleOf(workspaceChip)?.color ?? null, + workspaceChipHeight: workspaceChip + ? round(workspaceChip.getBoundingClientRect().height) + : null, + promptFont: styleOf(promptText)?.fontSize ?? null, + promptColor: styleOf(promptText)?.color ?? null, + promptLineHeight: styleOf(promptText)?.lineHeight ?? null, + toggleBackground: styleOf(toggle)?.backgroundColor ?? null, + toggleRadius: styleOf(toggle)?.borderRadius ?? null, + activeSegmentBackground: styleOf(activeSegment)?.backgroundColor ?? null, + activeSegmentShadow: styleOf(activeSegment)?.boxShadow ?? null, + activeSegmentFont: styleOf(activeSegment)?.fontSize ?? null, + }; + }); + await page.close(); + return values; +} + +const browser = await chromium.launch({ channel: 'chrome', headless: true }); +const oldValues = await measure(browser, OLD_URL); +const newValues = await measure(browser, NEW_URL); +await browser.close(); + +// Colors compare colorimetrically: the old site serializes sRGB, ours +// display-p3 — identical colors, different encodings. +function normalizeColor(value) { + if (typeof value !== 'string') { + return value; + } + const p3 = value.match( + /color\(display-p3 ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/, + ); + if (p3) { + const [, r, g, b, a] = p3; + return [r, g, b] + .map((channel) => Math.round(Number(channel) * 255)) + .concat([a === undefined ? 1 : Math.round(Number(a) * 1000) / 1000]) + .join(','); + } + const rgb = value.match( + /rgba?\(([\d.]+), ([\d.]+), ([\d.]+)(?:, ([\d.]+))?\)/, + ); + if (rgb) { + const [, r, g, b, a] = rgb; + return [r, g, b] + .map((channel) => Math.round(Number(channel))) + .concat([a === undefined ? 1 : Math.round(Number(a) * 1000) / 1000]) + .join(','); + } + return value; +} + +function colorsEqual(a, b) { + const left = normalizeColor(a).split(',').map(Number); + const right = normalizeColor(b).split(',').map(Number); + if (left.length !== right.length || left.some(Number.isNaN)) { + return normalizeColor(a) === normalizeColor(b); + } + return left.every( + (channel, index) => + Math.abs(channel - right[index]) <= (index === 3 ? 0.005 : 1), + ); +} + +const ledgeredKeys = new Set(LEDGER.map((entry) => entry.key)); +for (const key of Object.keys(oldValues)) { + const oldValue = oldValues[key]; + const newValue = newValues[key]; + const isColorKey = /color|background|border(?!.*Width)/i.test(key); + const equal = isColorKey + ? colorsEqual(String(oldValue), String(newValue)) + : String(oldValue) === String(newValue); + if (ledgeredKeys.has(key)) { + assert( + !equal, + `${key}: ledgered divergence present (old ${oldValue} vs new ${newValue})`, + ); + continue; + } + assert(equal, `${key}: ${oldValue} == ${newValue}`); +} + +if (failures.length > 0) { + console.error(`mockup-old-parity: FAILED (${failures.length})`); + process.exit(1); +} +console.log('mockup-old-parity: OK'); diff --git a/packages/twenty-website-redone/scripts/page-lock.mjs b/packages/twenty-website-redone/scripts/page-lock.mjs new file mode 100644 index 0000000000..1465be4776 --- /dev/null +++ b/packages/twenty-website-redone/scripts/page-lock.mjs @@ -0,0 +1,212 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + createBattery, + launchBrowser, + NEW_BASE, + openPage, +} from './battery-kit.mjs'; + +// Locks approved pages against THEMSELVES: a structured snapshot of the +// page's composition (sections, rhythm, intros, headings, CTAs, visual +// slots) committed as fixtures. Porting further pages must not move any +// of it; a deliberate change is re-recorded and reviewed as a fixture +// diff in the commit. +// +// node scripts/page-lock.mjs verify against fixtures +// node scripts/page-lock.mjs --record re-record fixtures +const LOCKED_PAGES = ['/', '/product']; + +// One viewport inside each breakpoint tier (sm 768 / md 921 / lg 1281), +// so every responsive variant of the approved pages is under lock — +// phone stacks and swipe decks, the sm band, the md band where the +// hero window crops, and the full desktop layout. +const LOCKED_VIEWPORTS = [ + { label: 'base-390', width: 390, height: 844 }, + { label: 'sm-820', width: 820, height: 900 }, + { label: 'md-1100', width: 1100, height: 900 }, + { label: 'lg-1440', width: 1440, height: 900 }, +]; +const FIXTURES_DIR = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'locks', +); + +const isRecording = process.argv.includes('--record'); + +function fixturePath(pagePath) { + const name = pagePath === '/' ? 'home' : pagePath.replaceAll('/', ''); + return path.join(FIXTURES_DIR, `${name}.json`); +} + +// The page's structural fingerprint: everything the house rules govern, +// read with stable selectors (no text anchors that break on copy edits +// — except headings, which ARE content under lock). +function readPageSnapshot(page) { + return page.evaluate(() => { + const sections = [...document.querySelectorAll('section')].map((el) => { + const previous = el.previousElementSibling; + return { + scheme: el.getAttribute('data-scheme'), + rhythm: el.getAttribute('data-rhythm'), + paddingTop: getComputedStyle(el).paddingTop, + paddingBottom: getComputedStyle(el).paddingBottom, + background: getComputedStyle(el).backgroundColor, + followsSameScheme: + previous?.tagName === 'SECTION' && + previous.getAttribute('data-scheme') === + el.getAttribute('data-scheme'), + }; + }); + + const eyebrows = [...document.querySelectorAll('p')] + .filter( + (el) => + el.querySelector('span[aria-hidden]') && + el.textContent.trim().length < 40, + ) + .map((row) => { + const block = row.parentElement; + const heading = + block.querySelector('h1, h2, h3') ?? + block.parentElement.querySelector('h1, h2, h3'); + const rect = row.getBoundingClientRect(); + const parentRect = row.parentElement.getBoundingClientRect(); + return { + label: row.textContent.trim(), + gapToHeading: heading + ? Math.round(heading.getBoundingClientRect().top - rect.bottom) + : null, + centered: + Math.abs( + rect.left - parentRect.left - (parentRect.right - rect.right), + ) <= 2, + }; + }); + + const headings = [...document.querySelectorAll('h1, h2')].map((el) => { + const headingStyle = getComputedStyle(el); + return { + tag: el.tagName, + text: el.textContent.trim().slice(0, 60), + fontSize: headingStyle.fontSize, + fontWeight: headingStyle.fontWeight, + textAlign: headingStyle.textAlign, + }; + }); + + const visualSlots = [...document.querySelectorAll('[data-illustration]')] + .map((el) => el.getAttribute('data-illustration')) + .toSorted(); + + const ctas = [...document.querySelectorAll('main a')] + .filter((el) => el.querySelector('svg, span[aria-hidden]')) + .slice(0, 12) + .map((el) => ({ + label: el.textContent.trim().slice(0, 30), + href: el.getAttribute('href'), + })); + + return { + sections, + eyebrows, + headings, + visualSlots, + ctas, + sectionCount: sections.length, + }; + }); +} + +function diffSnapshots(battery, pagePath, expected, actual) { + const walk = (keyPath, expectedValue, actualValue) => { + const expectedText = JSON.stringify(expectedValue); + const actualText = JSON.stringify(actualValue); + if (expectedText === actualText) return true; + + if ( + Array.isArray(expectedValue) && + Array.isArray(actualValue) && + expectedValue.length === actualValue.length + ) { + let allEqual = true; + expectedValue.forEach((item, index) => { + if (!walk(`${keyPath}[${index}]`, item, actualValue[index])) { + allEqual = false; + } + }); + return allEqual; + } + + battery.fail( + `${pagePath} ${keyPath}`, + `locked ${expectedText} vs live ${actualText}`, + ); + return false; + }; + + let clean = true; + for (const key of Object.keys(expected)) { + if (!walk(key, expected[key], actual[key])) clean = false; + } + if (clean) { + battery.ok( + `${pagePath} matches its lock`, + `${expected.sectionCount} sections, ${expected.eyebrows.length} eyebrows, ${expected.headings.length} headings`, + ); + } +} + +const battery = createBattery('page-lock'); +const browser = await launchBrowser(); + +for (const pagePath of LOCKED_PAGES) { + const snapshots = {}; + + for (const viewport of LOCKED_VIEWPORTS) { + // eslint-disable-next-line no-await-in-loop + const page = await openPage(browser, `${NEW_BASE}${pagePath}`, { + settleMs: 1200, + viewport, + }); + // eslint-disable-next-line no-await-in-loop + snapshots[viewport.label] = await readPageSnapshot(page); + // eslint-disable-next-line no-await-in-loop + await page.close(); + } + + if (isRecording) { + fs.mkdirSync(FIXTURES_DIR, { recursive: true }); + fs.writeFileSync( + fixturePath(pagePath), + `${JSON.stringify(snapshots, null, 2)}\n`, + ); + battery.ok(`${pagePath} recorded`, fixturePath(pagePath)); + continue; + } + + if (!fs.existsSync(fixturePath(pagePath))) { + battery.fail(`${pagePath} lock exists`, 'run with --record first'); + continue; + } + const expected = JSON.parse(fs.readFileSync(fixturePath(pagePath), 'utf8')); + for (const viewport of LOCKED_VIEWPORTS) { + if (!expected[viewport.label]) { + battery.fail( + `${pagePath} ${viewport.label} lock exists`, + 're-record (new viewport tier)', + ); + continue; + } + diffSnapshots( + battery, + `${pagePath} @${viewport.label}`, + expected[viewport.label], + snapshots[viewport.label], + ); + } +} + +await battery.finish(browser); diff --git a/packages/twenty-website-redone/scripts/partners-parity.mjs b/packages/twenty-website-redone/scripts/partners-parity.mjs new file mode 100644 index 0000000000..e06611c125 --- /dev/null +++ b/packages/twenty-website-redone/scripts/partners-parity.mjs @@ -0,0 +1,253 @@ +import { + createBattery, + launchBrowser, + NEW_BASE, + openPage, +} from './battery-kit.mjs'; + +// Invariant battery for the partners landing. The hero/promo WebGL visuals are +// reserved frames that differ from :3002 by design, so this pins the redone's +// OWN load-bearing mechanisms rather than an A/B: the connectsUp frame seam +// (the audit flagged it as net-new behaviour on a shared, locked primitive with +// no test), the localized case-study count, and every hero's h1. +const { fail, finish, ok } = createBattery('partners-parity'); + +const assert = (label, condition, detail) => + condition ? ok(label, detail) : fail(label, detail); + +const browser = await launchBrowser(); + +// connectsUp: the promo makes the TrustedBy band above it yield its bottom +// rhythm and show overflow, so the two read as one continuous frame across the +// seam. If a future refactor of the `:has(+ [data-connect-up])` rule breaks +// this, the corner markers re-clip and the frame splits. +const partners = await openPage(browser, `${NEW_BASE}/partners`, { + viewport: { width: 1440, height: 1400 }, + settleMs: 500, +}); +const seam = await partners.evaluate(() => { + const band = [...document.querySelectorAll('section')].find((s) => + /trusted by/i.test(s.textContent || ''), + ); + const style = getComputedStyle(band); + return { + bottom: style.paddingBottom, + overflow: style.overflowY, + hasCount: /\d+ Case Studies/.test(document.body.textContent || ''), + }; +}); +assert( + 'connectsUp drops the band bottom rhythm', + seam.bottom === '0px', + `padding-bottom ${seam.bottom}`, +); +assert( + 'connectsUp shows the band overflow', + seam.overflow === 'visible', + `overflow-y ${seam.overflow}`, +); +assert('case-study count renders', seam.hasCount, 'no "N Case Studies"'); + +// Partner testimonials: a dark notched panel on a light section. The notch +// reveals the section's WHITE surface (the gray-notch defect we fixed), the +// panel fill is the dark scheme surface, and the carousel adopts the dark +// scheme so its content ink resolves to light — the half-applied scheme that +// left the name/quote/counter invisible (vars set, `color` not) is what this +// guards. The counter is centred and the quote runs sans, both matching the +// home testimonials (the quote shares the counter's sans family). +const panel = await partners.evaluate(() => { + const carousel = document.querySelector( + '[aria-label="Partner testimonials"]', + ); + const section = carousel?.closest('section'); + const shape = section?.querySelector('[data-card-scheme]'); + const bodyFill = shape?.lastElementChild; + const counter = [...(section?.querySelectorAll('p') ?? [])].find((node) => + /^\d+\/\d+$/.test((node.textContent ?? '').trim()), + ); + const quote = section?.querySelector('h2'); + return { + surface: section ? getComputedStyle(section).backgroundColor : '', + cardScheme: shape?.getAttribute('data-card-scheme') ?? '', + fill: bodyFill ? getComputedStyle(bodyFill).backgroundColor : '', + ink: carousel ? getComputedStyle(carousel).color : '', + counterAlign: counter ? getComputedStyle(counter).textAlign : '', + counterFont: counter ? getComputedStyle(counter).fontFamily : '', + quoteFont: quote ? getComputedStyle(quote).fontFamily : '', + }; +}); +assert( + 'testimonials notch reveals a white surface', + panel.surface === 'rgb(255, 255, 255)', + panel.surface, +); +assert( + 'testimonials panel fills with the dark surface', + panel.cardScheme === 'dark' && panel.fill === 'rgb(28, 28, 28)', + `${panel.cardScheme} / ${panel.fill}`, +); +assert( + 'testimonials content adopts the light ink', + panel.ink === 'rgb(255, 255, 255)', + panel.ink, +); +assert( + 'testimonials counter is centred', + panel.counterAlign === 'center', + panel.counterAlign, +); +assert( + 'testimonials quote shares the counter sans family', + panel.quoteFont !== '' && panel.quoteFont === panel.counterFont, + `quote ${panel.quoteFont} / counter ${panel.counterFont}`, +); + +// The closing sign-off: a tall centred panel whose heading carries both faces +// (serif + sans accent). "Become a partner" is the application-modal trigger (a +// button, no href); "Find a partner" links to the (Wave-B) marketplace, the +// same dangle the hero already carries. The body is measure-constrained so it +// breaks across two lines rather than running the panel width. +const signoff = await partners.evaluate(() => { + const heading = [...document.querySelectorAll('h2')].find((node) => + /Ready to grow/.test(node.textContent ?? ''), + ); + const section = heading?.closest('section'); + const ctas = [...(section?.querySelectorAll('a, button') ?? [])]; + const become = ctas.find((node) => + /Become a partner/i.test(node.textContent ?? ''), + ); + const find = ctas.find((node) => + /Find a partner/i.test(node.textContent ?? ''), + ); + const body = [...(section?.querySelectorAll('p') ?? [])].find((node) => + /partner ecosystem/.test(node.textContent ?? ''), + ); + return { + headingText: (heading?.textContent ?? '').replace(/\s+/g, ' ').trim(), + becomeTag: become?.tagName.toLowerCase() ?? '', + becomeHref: become?.getAttribute('href') ?? null, + findHref: find?.getAttribute('href') ?? '', + bodyWidth: body ? Math.round(body.getBoundingClientRect().width) : 0, + sectionHeight: section + ? Math.round(section.getBoundingClientRect().height) + : 0, + hasCrosshair: Boolean(section?.querySelector('[data-slot="plus"]')), + }; +}); +assert( + 'signoff heading carries both faces', + signoff.headingText.includes('Ready to grow') && + signoff.headingText.includes('with Twenty?'), + signoff.headingText, +); +assert( + 'signoff become-a-partner is a modal trigger, not a link', + signoff.becomeTag === 'button' && signoff.becomeHref === null, + `${signoff.becomeTag} href=${signoff.becomeHref}`, +); +assert( + 'signoff find-a-partner links to the marketplace', + signoff.findHref.endsWith('/partners/list'), + signoff.findHref, +); +assert( + 'signoff body is measure-constrained', + signoff.bodyWidth > 0 && signoff.bodyWidth <= 410, + `${signoff.bodyWidth}px`, +); +assert( + 'signoff is a tall centred panel', + signoff.sectionHeight >= 759 && signoff.hasCrosshair, + `${signoff.sectionHeight}px / crosshair ${signoff.hasCrosshair}`, +); + +// The application modal: clicking "Become a partner" opens a dark dialog that +// hosts the deferred wizard. The dialog carries the wizard's "Apply to build" +// title on the near-black panel, runs on the dark scheme, and its width is set +// up to ease between the form (<=720) and booking widths rather than snapping. +await partners.evaluate(() => { + const become = [...document.querySelectorAll('button')].find((node) => + /Become a partner/i.test(node.textContent ?? ''), + ); + become?.click(); +}); +const modal = await partners + .waitForFunction( + () => + /Apply to build/.test( + document.querySelector('[role="dialog"]')?.textContent ?? '', + ), + { timeout: 5000 }, + ) + .then(() => + partners.evaluate(() => { + const dialog = document.querySelector('[role="dialog"]'); + const style = dialog ? getComputedStyle(dialog) : null; + return { + opened: Boolean(dialog), + darkScope: Boolean(dialog?.querySelector('[data-scheme="dark"]')), + panel: style?.backgroundColor ?? '', + transition: style?.transitionProperty ?? '', + width: dialog ? Math.round(dialog.getBoundingClientRect().width) : 0, + }; + }), + ) + .catch(() => ({ + opened: false, + darkScope: false, + panel: '', + transition: '', + width: 0, + })); +assert( + 'become-a-partner opens the dark application modal', + modal.opened && modal.darkScope, + `opened=${modal.opened} darkScope=${modal.darkScope}`, +); +assert( + 'application modal sits on the near-black panel', + modal.panel === 'rgb(12, 12, 12)', + modal.panel, +); +assert( + 'application modal eases between form and booking widths', + /width/.test(modal.transition) && modal.width <= 720, + `transition="${modal.transition}" width=${modal.width}`, +); +await partners.close(); + +// Every hero renders its h1 (the shared hero composition holds across schemes). +async function heroH1(path) { + const page = await openPage(browser, `${NEW_BASE}${path}`, { + viewport: { width: 1440, height: 1000 }, + settleMs: 300, + }); + const text = await page.evaluate( + () => document.querySelector('main h1')?.textContent || '', + ); + await page.close(); + return text; +} + +assert( + 'partners hero h1', + (await heroH1('/partners')).includes('our partner'), + 'missing', +); +assert( + 'why-twenty hero h1', + (await heroH1('/why-twenty')).includes('not bought'), + 'missing', +); +assert( + 'releases hero h1', + (await heroH1('/releases')).includes('Releases'), + 'missing', +); +assert( + 'customers hero h1', + (await heroH1('/customers')).includes('on Twenty'), + 'missing', +); + +await finish(browser); diff --git a/packages/twenty-website-redone/scripts/pending-visual-slots.mjs b/packages/twenty-website-redone/scripts/pending-visual-slots.mjs new file mode 100644 index 0000000000..08f875f648 --- /dev/null +++ b/packages/twenty-website-redone/scripts/pending-visual-slots.mjs @@ -0,0 +1,5 @@ +// Slots staged for the AppPreview wave — mounted as reserved boxes, no +// visual yet, BY PLAN. The single declaration both batteries consume: +// the sweep tolerates them empty (and fails once they go live), the +// battery's spec-completeness check exempts them. Burn down per commit. +export const PENDING_VISUAL_SLOTS = new Set([]); diff --git a/packages/twenty-website-redone/scripts/pricing-plans-parity.mjs b/packages/twenty-website-redone/scripts/pricing-plans-parity.mjs new file mode 100644 index 0000000000..4541a9eb8b --- /dev/null +++ b/packages/twenty-website-redone/scripts/pricing-plans-parity.mjs @@ -0,0 +1,130 @@ +import { + createBattery, + launchBrowser, + NEW_BASE, + OLD_BASE, + openPage, +} from './battery-kit.mjs'; + +// A/B battery for the pricing plans: both cards' static anatomy, and the +// two toggles that drive the shared hosting state and local billing +// state through the price counter and the feature-list transition. +const { compare, fail, finish, ok } = createBattery('pricing-plans-parity'); + +const browser = await launchBrowser(); + +// Reads the two plan cards: heading, price value/suffix, CTA, bullets. +function readPlanCards(page) { + return page.evaluate(() => { + const headings = [...document.querySelectorAll('h3')].filter((el) => + ['Pro', 'Organization'].includes(el.textContent.trim()), + ); + return headings.map((heading) => { + const card = heading.closest('div[class]')?.parentElement?.parentElement; + const scope = card ?? heading.closest('section'); + const priceHeading = scope?.querySelector('h4'); + const suffix = priceHeading?.nextElementSibling; + const cta = scope?.querySelector('a'); + const bullets = [...scope.querySelectorAll('li')] + .filter((li) => li.offsetParent !== null) + .map((li) => li.textContent.trim()); + return { + heading: heading.textContent.trim(), + price: priceHeading?.textContent.trim() ?? null, + suffix: suffix?.textContent.trim() ?? null, + ctaLabel: cta?.textContent.trim() ?? null, + bullets, + }; + }); + }); +} + +async function scrollToPlans(page) { + await page.evaluate(() => { + const toggle = [...document.querySelectorAll('button')].find((button) => + button.textContent.includes('Monthly'), + ); + toggle?.scrollIntoView({ block: 'center' }); + }); + await page.waitForTimeout(700); +} + +async function clickToggle(page, label) { + await page.evaluate((needle) => { + const target = [...document.querySelectorAll('button, label')].find((el) => + el.textContent.includes(needle), + ); + if (target instanceof HTMLElement) target.click(); + }, label); + // Past the feature transition (110ms + stagger) and the price tween (500ms). + await page.waitForTimeout(900); +} + +const oldPage = await openPage(browser, `${OLD_BASE}/pricing`, { + settleMs: 1200, +}); +const newPage = await openPage(browser, `${NEW_BASE}/pricing`, { + settleMs: 1200, +}); + +await scrollToPlans(oldPage); +await scrollToPlans(newPage); + +// --- Default state (cloud + yearly) ------------------------------------ +const oldDefault = await readPlanCards(oldPage); +const newDefault = await readPlanCards(newPage); + +if (oldDefault.length !== 2 || newDefault.length !== 2) { + fail( + 'two plan cards on both sites', + `old ${oldDefault.length} new ${newDefault.length}`, + ); +} else { + compare('default cards (cloud + yearly)', oldDefault, newDefault); + + // CTA variants: Pro outlined, Organization filled (our button system + // expresses fill via a shape layer, so the variant lives on the data + // attribute rather than the link's background — the old site's + // contained/outlined maps onto it). + const ctaVariants = await newPage.evaluate(() => + [...document.querySelectorAll('a')] + .filter((a) => a.textContent.includes('Start for free')) + .map((a) => a.getAttribute('data-variant')), + ); + compare('Pro CTA outlined, Organization CTA filled', ctaVariants, [ + 'outlined', + 'filled', + ]); +} + +// --- Billing toggle: yearly -> monthly raises Pro to $12 ---------------- +await clickToggle(oldPage, 'Monthly'); +await clickToggle(newPage, 'Monthly'); +const oldMonthly = await readPlanCards(oldPage); +const newMonthly = await readPlanCards(newPage); +compare('monthly billing cards', oldMonthly, newMonthly); +if (newMonthly[0].price.includes('12')) { + ok('Pro rises to $12 on monthly billing'); +} else { + fail('Pro rises to $12 on monthly billing', newMonthly[0].price); +} + +// --- Self-host toggle: Pro drops to $0, bullets swap to self-host ------- +await clickToggle(oldPage, 'Selfhosting'); +await clickToggle(newPage, 'Selfhosting'); +const oldSelfHost = await readPlanCards(oldPage); +const newSelfHost = await readPlanCards(newPage); +compare('self-host cards', oldSelfHost, newSelfHost); +if ( + newSelfHost[0].price.includes('0') && + newSelfHost[1].bullets.some((bullet) => bullet.includes('Custom AI models')) +) { + ok('self-host swaps Pro to $0 and Organization bullets to the self-host set'); +} else { + fail( + 'self-host swaps Pro to $0 and Organization bullets to the self-host set', + JSON.stringify(newSelfHost.map((card) => card.price)), + ); +} + +await finish(browser); diff --git a/packages/twenty-website-redone/scripts/product-demo-parity.mjs b/packages/twenty-website-redone/scripts/product-demo-parity.mjs new file mode 100644 index 0000000000..8a339967eb --- /dev/null +++ b/packages/twenty-website-redone/scripts/product-demo-parity.mjs @@ -0,0 +1,168 @@ +import { + createBattery, + launchBrowser, + NEW_BASE, + OLD_BASE, + VIEWPORT, +} from './battery-kit.mjs'; + +// A/B battery for the product demo closer: centered intro, the emergent +// two-line heading break, CTA chrome, the pattern backdrop, and the +// static (terminal-less) mockup. + +const { compare, fail, finish, ok } = createBattery('product-demo-parity'); + +const browser = await launchBrowser(); + +async function readDemo(base) { + const page = await browser.newPage({ viewport: VIEWPORT }); + await page.goto(`${base}/product`, { waitUntil: 'load', timeout: 240000 }); + await page.waitForTimeout(1200); + // Park on the section first so the lazy pattern image loads. + await page.evaluate(() => { + const heading = [...document.querySelectorAll('h2')].find((el) => + el.textContent.includes('thousand words'), + ); + heading?.scrollIntoView({ block: 'start' }); + }); + await page.waitForTimeout(1200); + const state = await page.evaluate(() => { + const heading = [...document.querySelectorAll('h2')].find((el) => + el.textContent.includes('thousand words'), + ); + if (!heading) return null; + heading.scrollIntoView({ block: 'center' }); + const headingStyle = getComputedStyle(heading); + + // First-line text: walk characters until the line top jumps. + const range = document.createRange(); + const walker = document.createTreeWalker(heading, NodeFilter.SHOW_TEXT); + let firstLineTop = null; + let firstLine = ''; + let node; + outer: while ((node = walker.nextNode())) { + for (let index = 0; index < node.textContent.length; index += 1) { + range.setStart(node, index); + range.setEnd(node, index + 1); + const rect = range.getBoundingClientRect(); + if (rect.width === 0) continue; + if (firstLineTop === null) firstLineTop = rect.top; + if (Math.abs(rect.top - firstLineTop) > 5) break outer; + firstLine += node.textContent[index]; + } + } + + const root = heading.closest('section') ?? heading.closest('div'); + const cta = [...root.querySelectorAll('a')].find((el) => + el.textContent.includes('Try Twenty Cloud'), + ); + const ctaStyle = cta ? getComputedStyle(cta) : null; + + // The pattern layer is a sibling of the section on the old site; + // search from the shared wrapper. + const wrapper = root.parentElement ?? root; + const pattern = [...wrapper.querySelectorAll('img')].find((img) => + decodeURIComponent(img.currentSrc).includes('product/demo/background'), + ); + const patternLayer = pattern?.closest('div[aria-hidden]'); + const patternStyle = patternLayer ? getComputedStyle(patternLayer) : null; + + const ctaToMockupGap = (() => { + const ctaRect = cta?.getBoundingClientRect(); + if (!ctaRect) return null; + const frameBelow = [...document.querySelectorAll('main div')].find( + (el) => { + const rect = el.getBoundingClientRect(); + return ( + rect.width >= 1000 && + rect.top > ctaRect.bottom && + getComputedStyle(el).borderRadius === '20px' + ); + }, + ); + return frameBelow + ? Math.round(frameBelow.getBoundingClientRect().top - ctaRect.bottom) + : null; + })(); + + // The static mockup window: the 20px-radius frame at the scene's + // 1040x676 (old aspect 1280/832 reduces to the same 20:13). + const frame = [...document.querySelectorAll('div')].find((el) => { + const rect = el.getBoundingClientRect(); + return ( + rect.width >= 900 && + rect.top > heading.getBoundingClientRect().top && + getComputedStyle(el).borderRadius === '20px' && + Math.abs(rect.width / rect.height - 20 / 13) < 0.01 && + el.textContent.includes('Companies') + ); + }); + + return { + ctaToMockupGap, + firstLine: firstLine.trim(), + headingStyle: `${headingStyle.fontSize} ${headingStyle.fontWeight} ${headingStyle.textAlign}`, + ctaHref: cta?.getAttribute('href') ?? null, + ctaInk: ctaStyle ? `${ctaStyle.backgroundColor} ${ctaStyle.color}` : null, + patternLoaded: Boolean(pattern && pattern.naturalWidth > 0), + patternOpacity: patternStyle?.opacity ?? null, + mockupPresent: Boolean(frame), + mockupRows: frame + ? frame.textContent.includes('Anthropic') && + frame.textContent.includes('Stripe') + : false, + }; + }); + await page.close(); + return state; +} + +const oldDemo = await readDemo(OLD_BASE); +const newDemo = await readDemo(NEW_BASE); + +if (!oldDemo || !newDemo) { + fail( + 'demo section present on both sites', + `old=${Boolean(oldDemo)} new=${Boolean(newDemo)}`, + ); +} else { + compare( + 'heading first line breaks after "a"', + oldDemo.firstLine, + newDemo.firstLine, + ); + compare('heading style', oldDemo.headingStyle, newDemo.headingStyle); + compare('CTA href', oldDemo.ctaHref, newDemo.ctaHref); + compare('CTA chrome', oldDemo.ctaInk, newDemo.ctaInk); + compare( + 'pattern backdrop at 0.6 over the lower stage', + `${oldDemo.patternLoaded} ${oldDemo.patternOpacity}`, + `${newDemo.patternLoaded} ${newDemo.patternOpacity}`, + ); + compare( + 'static mockup with the companies fiction', + `${oldDemo.mockupPresent} ${oldDemo.mockupRows}`, + `${newDemo.mockupPresent} ${newDemo.mockupRows}`, + ); + + // RATIFIED DIVERGENCE (user, 2026-06-13): the demo hangs the mockup at + // the hero's CTA-to-window measure (68px token) instead of the old + // demo's looser stack spacing — the two CTA-over-mockup moments read + // the same. Asserting the difference keeps it deliberate. + if ( + newDemo.ctaToMockupGap === 68 && + oldDemo.ctaToMockupGap !== newDemo.ctaToMockupGap + ) { + ok( + 'demo mockup hangs at the hero CTA gap (ledgered divergence)', + `old ${oldDemo.ctaToMockupGap}px vs new ${newDemo.ctaToMockupGap}px`, + ); + } else { + fail( + 'demo mockup hangs at the hero CTA gap (ledgered divergence)', + `old ${oldDemo.ctaToMockupGap}px vs new ${newDemo.ctaToMockupGap}px`, + ); + } +} + +await finish(browser); diff --git a/packages/twenty-website-redone/scripts/product-feature-parity.mjs b/packages/twenty-website-redone/scripts/product-feature-parity.mjs new file mode 100644 index 0000000000..5447e3de98 --- /dev/null +++ b/packages/twenty-website-redone/scripts/product-feature-parity.mjs @@ -0,0 +1,454 @@ +import { + createBattery, + launchBrowser, + NEW_BASE, + OLD_BASE, + openPage, +} from './battery-kit.mjs'; + +// A/B battery for the ProductFeature section: both sites are driven down +// the tile grid and compared on lattice styles, tile content styles, the +// counter format, the entrance fade, and the interactive visuals +// (donut sweep, contacts checkbox + drag-scroll, kanban drag-drop-reorder +// with FLIP). +const OLD_URL = `${OLD_BASE}/product`; +const NEW_URL = `${NEW_BASE}/product`; + +const { compare, fail, finish, ok } = createBattery('product-feature-parity'); + +const browser = await launchBrowser(); + +async function scrollToText(page, text, settleMs) { + const found = await page.evaluate((needle) => { + // The eyebrow is a span on the old site and a p (house Eyebrow) here. + const target = [...document.querySelectorAll('span, p, h2, h3')].find( + (el) => el.textContent.trim() === needle, + ); + if (!target) return false; + target.scrollIntoView({ block: 'center' }); + return true; + }, text); + await page.waitForTimeout(settleMs); + return found; +} + +// The grid + tile anatomy, read from the counters' shared lattice. +function readSectionAnatomy(page) { + return page.evaluate(() => { + const counters = [...document.querySelectorAll('span')].filter((el) => + /^\d{2} \/ \d{2}$/.test(el.textContent.trim()), + ); + if (counters.length === 0) return null; + + let grid = counters[0].parentElement; + while (grid) { + const style = getComputedStyle(grid); + const holdsAll = counters.every((counter) => grid.contains(counter)); + if (holdsAll && style.borderTopWidth === '1px') break; + grid = grid.parentElement; + } + const gridStyle = grid ? getComputedStyle(grid) : null; + + const cellEdges = grid + ? [...grid.children].map((cell) => { + const style = getComputedStyle(cell); + return `${style.borderRightWidth} ${style.borderBottomWidth}`; + }) + : []; + + let entrance = counters[0].parentElement; + while (entrance) { + const style = getComputedStyle(entrance); + if ( + style.transitionProperty.includes('opacity') && + style.transitionDuration.includes('0.6s') + ) + break; + entrance = entrance.parentElement; + } + const entranceStyle = entrance ? getComputedStyle(entrance) : null; + + const headerRow = counters[0].parentElement; + const category = headerRow?.querySelector('span'); + const categoryStyle = category ? getComputedStyle(category) : null; + const counterStyle = getComputedStyle(counters[0]); + const heading = [...document.querySelectorAll('h3')].find((el) => + grid?.contains(el), + ); + const headingStyle = heading ? getComputedStyle(heading) : null; + + return { + counters: counters.map((el) => el.textContent.trim()), + gridBorder: gridStyle + ? `${gridStyle.borderTopWidth} ${gridStyle.borderTopColor} r${gridStyle.borderRadius}` + : null, + cellEdges, + entranceTransition: entranceStyle + ? `${entranceStyle.transitionProperty} ${entranceStyle.transitionDuration}` + : null, + entranceOpacity: entranceStyle?.opacity ?? null, + categoryStyle: categoryStyle + ? `${categoryStyle.fontFamily.slice(0, 24)} ${categoryStyle.fontSize} ${categoryStyle.letterSpacing} ${categoryStyle.textTransform} ${categoryStyle.color}` + : null, + counterStyle: `${counterStyle.fontSize} ${counterStyle.color}`, + headingStyle: headingStyle + ? `${headingStyle.fontSize} ${headingStyle.fontWeight} ${headingStyle.color}` + : null, + }; + }); +} + +// The donut's value arc: the dasharrayed circle with the 1.2s sweep. +function readDonutState(page) { + return page.evaluate(() => { + const circle = [...document.querySelectorAll('circle')].find((el) => { + const style = getComputedStyle(el); + return ( + style.strokeDasharray !== 'none' && + style.transitionDuration.includes('1.2s') + ); + }); + if (!circle) return null; + const style = getComputedStyle(circle); + return { + transition: `${style.transitionProperty} ${style.transitionDuration}`, + dashoffset: Number.parseFloat(style.strokeDashoffset), + }; + }); +} + +// The contacts mini-table root, found from its view header. The hero's +// mockup also shows an "All Companies" view, so the match is scoped to +// the dark feature-card surface (#1d1d25). +const CONTACTS_ROOT = ` + const root = [...document.querySelectorAll('span')] + .filter((el) => el.textContent.trim() === 'All Companies') + .map((el) => el.parentElement?.parentElement) + .find( + (candidate) => + candidate && + getComputedStyle(candidate).backgroundColor === 'rgb(29, 29, 37)', + ); +`; + +// "All Companies" first matches the hero viewbar, so scrolling goes +// through the scoped root rather than scrollToText. +async function scrollToContacts(page) { + await page.evaluate(`(() => { + ${CONTACTS_ROOT} + root?.scrollIntoView({ block: 'center' }); + })()`); + await page.waitForTimeout(1400); +} + +function readContactsCheckbox(page) { + return page.evaluate(`(() => { + ${CONTACTS_ROOT} + if (!root) return null; + const boxes = [...root.querySelectorAll('div')].filter((el) => { + const style = getComputedStyle(el); + return style.width === '14px' && style.borderRadius === '3px'; + }); + const rowBox = boxes[1]; + if (!rowBox) return null; + const rect = rowBox.getBoundingClientRect(); + const style = getComputedStyle(rowBox); + return { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + background: style.backgroundColor, + borderColor: style.borderTopColor, + }; + })()`); +} + +function readContactsViewport(page) { + return page.evaluate(`(() => { + ${CONTACTS_ROOT} + if (!root) return null; + const viewport = [...root.querySelectorAll('div')].find( + (el) => getComputedStyle(el).overflowX === 'auto', + ); + if (!viewport) return null; + const rect = viewport.getBoundingClientRect(); + return { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + scrollLeft: viewport.scrollLeft, + }; + })()`); +} + +// The pipeline board, found from its header; cards carry OPP- record ids. +function readPipelineState(page) { + return page.evaluate(() => { + const title = [...document.querySelectorAll('span')].find( + (el) => el.textContent.trim() === 'All opportunities', + ); + const root = title?.parentElement?.parentElement; + if (!root) return null; + + const counts = ['Identified', 'Qualified'].map((label) => { + const pill = [...root.querySelectorAll('span')].find( + (el) => el.textContent.trim() === label, + ); + return pill?.nextElementSibling?.textContent.trim() ?? null; + }); + + const cardRect = (recordId) => { + const cards = [...root.querySelectorAll('div')].filter( + (el) => + getComputedStyle(el).cursor === 'grab' && + el.textContent.includes(recordId), + ); + const card = cards.at(-1); + if (!card) return null; + const rect = card.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }; + + return { + counts, + github: cardRect('OPP-1'), + airbnb: cardRect('OPP-8'), + }; + }); +} + +function countFlipAnimations(page) { + return page.evaluate( + () => + document + .getAnimations() + .filter( + (animation) => + animation.constructor.name === 'Animation' && + animation.playState === 'running', + ).length, + ); +} + +const oldPage = await openPage(browser, OLD_URL); +const newPage = await openPage(browser, NEW_URL); +const pages = [ + ['old', oldPage], + ['new', newPage], +]; + +// --- Section anatomy + lattice + entrance + tile styles --------------- +const anatomies = {}; +for (const [name, page] of pages) { + // eslint-disable-next-line no-await-in-loop + const found = await scrollToText(page, 'Core Features', 1100); + if (!found) fail(`feature section present on ${name} site`, 'no eyebrow'); + // eslint-disable-next-line no-await-in-loop + anatomies[name] = await readSectionAnatomy(page); +} + +if (!anatomies.old || !anatomies.new) { + fail( + 'feature tiles readable on both sites', + `old=${Boolean(anatomies.old)} new=${Boolean(anatomies.new)}`, + ); +} else { + compare( + 'tile counters match', + anatomies.old.counters, + anatomies.new.counters, + ); + compare( + 'grid lattice border', + anatomies.old.gridBorder, + anatomies.new.gridBorder, + ); + compare( + 'cell edge lattice', + anatomies.old.cellEdges, + anatomies.new.cellEdges, + ); + compare( + 'entrance fade transition', + anatomies.old.entranceTransition, + anatomies.new.entranceTransition, + ); + compare( + 'entrance settled opacity', + anatomies.old.entranceOpacity, + anatomies.new.entranceOpacity, + ); + compare( + 'category label style', + anatomies.old.categoryStyle, + anatomies.new.categoryStyle, + ); + compare( + 'counter style', + anatomies.old.counterStyle, + anatomies.new.counterStyle, + ); + compare( + 'tile heading style', + anatomies.old.headingStyle, + anatomies.new.headingStyle, + ); +} + +// --- Donut sweep (spotlight active state) ------------------------------ +const donuts = {}; +for (const [name, page] of pages) { + // eslint-disable-next-line no-await-in-loop + await scrollToText(page, 'Sales performances', 1700); + // eslint-disable-next-line no-await-in-loop + donuts[name] = await readDonutState(page); +} +if (!donuts.old || !donuts.new) { + fail( + 'donut arc present on both sites', + `old=${Boolean(donuts.old)} new=${Boolean(donuts.new)}`, + ); +} else { + compare( + 'donut sweep transition', + donuts.old.transition, + donuts.new.transition, + ); + if (Math.abs(donuts.old.dashoffset - donuts.new.dashoffset) <= 1) { + ok( + 'donut sweep settles at the same arc', + `old ${donuts.old.dashoffset.toFixed(1)} vs new ${donuts.new.dashoffset.toFixed(1)}`, + ); + } else { + fail( + 'donut sweep settles at the same arc', + `old ${donuts.old.dashoffset} vs new ${donuts.new.dashoffset}`, + ); + } +} + +// --- Contacts: checkbox + drag-scroll ---------------------------------- +const checkboxes = {}; +for (const [name, page] of pages) { + // eslint-disable-next-line no-await-in-loop + await scrollToContacts(page); + // eslint-disable-next-line no-await-in-loop + const before = await readContactsCheckbox(page); + if (!before) { + fail(`contacts checkbox found on ${name} site`, 'no 14px box'); + continue; + } + // eslint-disable-next-line no-await-in-loop + await page.mouse.click(before.x, before.y); + // eslint-disable-next-line no-await-in-loop + await page.waitForTimeout(250); + // eslint-disable-next-line no-await-in-loop + checkboxes[name] = { before, after: await readContactsCheckbox(page) }; +} +if (checkboxes.old && checkboxes.new) { + compare( + 'checkbox unchecked chrome', + `${checkboxes.old.before.background} ${checkboxes.old.before.borderColor}`, + `${checkboxes.new.before.background} ${checkboxes.new.before.borderColor}`, + ); + compare( + 'checkbox checked chrome', + `${checkboxes.old.after.background} ${checkboxes.old.after.borderColor}`, + `${checkboxes.new.after.background} ${checkboxes.new.after.borderColor}`, + ); + if (checkboxes.new.before.background !== checkboxes.new.after.background) { + ok('checkbox toggles on click'); + } else { + fail('checkbox toggles on click', 'background unchanged'); + } +} + +const scrolls = {}; +for (const [name, page] of pages) { + // eslint-disable-next-line no-await-in-loop + const viewport = await readContactsViewport(page); + if (!viewport) { + fail(`contacts viewport found on ${name} site`, 'no overflow-x element'); + continue; + } + // eslint-disable-next-line no-await-in-loop + await page.mouse.move(viewport.x, viewport.y); + // eslint-disable-next-line no-await-in-loop + await page.mouse.down(); + for (const step of [40, 80, 120, 160]) { + // eslint-disable-next-line no-await-in-loop + await page.mouse.move(viewport.x - step, viewport.y); + } + // eslint-disable-next-line no-await-in-loop + await page.mouse.up(); + // eslint-disable-next-line no-await-in-loop + const after = await readContactsViewport(page); + scrolls[name] = { from: viewport.scrollLeft, to: after?.scrollLeft ?? null }; +} +if (scrolls.old && scrolls.new) { + if (scrolls.old.to > scrolls.old.from && scrolls.new.to > scrolls.new.from) { + ok( + 'contacts table drag-scrolls on both sites', + `old +${scrolls.old.to - scrolls.old.from}px, new +${scrolls.new.to - scrolls.new.from}px`, + ); + } else { + fail( + 'contacts table drag-scrolls on both sites', + `old ${JSON.stringify(scrolls.old)} vs new ${JSON.stringify(scrolls.new)}`, + ); + } +} + +// --- Pipeline: drag-drop-reorder + FLIP -------------------------------- +for (const [name, page] of pages) { + // eslint-disable-next-line no-await-in-loop + await scrollToText(page, 'All opportunities', 1400); + // eslint-disable-next-line no-await-in-loop + const before = await readPipelineState(page); + if (!before?.github || !before.airbnb) { + fail(`pipeline cards found on ${name} site`, JSON.stringify(before)); + continue; + } + compare(`pipeline lane counts at rest (${name})`, before.counts, ['2', '2']); + + const grabX = before.github.x + before.github.width / 2; + const grabY = before.github.y + 12; + // Drop above Airbnb's midpoint: lane 2, index 0 — both resident cards + // get displaced, so the FLIP animations must fire. + const dropX = before.airbnb.x + before.airbnb.width / 2; + const dropY = before.airbnb.y + 5; + + // eslint-disable-next-line no-await-in-loop + await page.mouse.move(grabX, grabY); + // eslint-disable-next-line no-await-in-loop + await page.mouse.down(); + for (const step of [0.25, 0.5, 0.75, 1]) { + // eslint-disable-next-line no-await-in-loop + await page.mouse.move( + grabX + (dropX - grabX) * step, + grabY + (dropY - grabY) * step, + ); + } + // eslint-disable-next-line no-await-in-loop + await page.mouse.up(); + // eslint-disable-next-line no-await-in-loop + const flipCount = await countFlipAnimations(page); + // eslint-disable-next-line no-await-in-loop + await page.waitForTimeout(450); + // eslint-disable-next-line no-await-in-loop + const after = await readPipelineState(page); + + if (after && after.counts[0] === '1' && after.counts[1] === '3') { + ok(`pipeline drop reorders lanes on ${name} site`, '2/2 → 1/3'); + } else { + fail( + `pipeline drop reorders lanes on ${name} site`, + `counts ${JSON.stringify(after?.counts)}`, + ); + } + if (flipCount >= 1) { + ok(`displaced cards FLIP on ${name} site`, `${flipCount} running`); + } else { + fail(`displaced cards FLIP on ${name} site`, 'no WAAPI animation running'); + } +} + +await finish(browser); diff --git a/packages/twenty-website-redone/scripts/product-hero-parity.mjs b/packages/twenty-website-redone/scripts/product-hero-parity.mjs new file mode 100644 index 0000000000..908f17228c --- /dev/null +++ b/packages/twenty-website-redone/scripts/product-hero-parity.mjs @@ -0,0 +1,289 @@ +import { chromium } from 'playwright'; + +// A/B battery for the product hero's scroll choreography: both sites are +// driven to the same morph positions (derived from each site's own track +// geometry) and compared on the values the old site defines as law. +const OLD_URL = process.env.MOCKUP_OLD_URL ?? 'http://localhost:3002/product'; +const NEW_URL = + process.env.VISUAL_BATTERY_URL ?? 'http://localhost:3004/product'; + +const VIEWPORT = { width: 1440, height: 900 }; + +const failures = []; +const ok = (label, detail) => + console.log(` ✓ ${label}${detail ? ` (${detail})` : ''}`); +const fail = (label, detail) => { + failures.push(`${label}: ${detail}`); + console.log(` ✗ ${label}: ${detail}`); +}; + +const browser = await chromium.launch({ channel: 'chrome', headless: true }); + +async function openHero(url) { + const page = await browser.newPage({ viewport: VIEWPORT }); + await page.goto(url, { waitUntil: 'load', timeout: 240000 }); + await page.waitForTimeout(1200); + return page; +} + +// The 200vh scroll track, found by its geometry on both sites. +async function trackGeometry(page) { + return page.evaluate(() => { + const candidates = [...document.querySelectorAll('section, div')]; + const track = candidates.find( + (el) => + Math.abs(el.offsetHeight - window.innerHeight * 2) < 8 && + el.querySelector('h1, h2'), + ); + if (!track) return null; + const rect = track.getBoundingClientRect(); + return { + top: rect.top + window.scrollY, + height: track.offsetHeight, + }; + }); +} + +async function scrollToProgress(page, geometry, progress) { + const scrollable = geometry.height - VIEWPORT.height; + await page.evaluate(({ top }) => window.scrollTo(0, top), { + top: geometry.top + progress * scrollable, + }); + await page.waitForTimeout(450); +} + +// The dark AI layer: the element carrying a clip-path inset. +async function readHeroState(page) { + return page.evaluate(() => { + const layers = [...document.querySelectorAll('div')]; + const dark = layers.find((el) => { + const style = getComputedStyle(el); + return ( + style.clipPath !== 'none' && + style.clipPath.includes('inset') && + style.position === 'absolute' + ); + }); + // The sticky menu bar: 64px tall, pinned to the top on both sites. + const header = [...document.querySelectorAll('header, section')].find( + (el) => { + const style = getComputedStyle(el); + return ( + (style.position === 'fixed' || style.position === 'sticky') && + el.getBoundingClientRect().top <= 1 && + el.offsetHeight >= 40 && + el.offsetHeight <= 120 + ); + }, + ); + const headerStyle = header ? getComputedStyle(header) : null; + const cursorNames = ['Alice', 'Ben', 'Cara'].filter((name) => + [...document.querySelectorAll('span')].some( + (el) => + el.textContent === name.toUpperCase() || el.textContent === name, + ), + ); + const darkStyle = dark ? getComputedStyle(dark) : null; + return { + clipPath: darkStyle?.clipPath ?? null, + darkBackground: darkStyle?.backgroundColor ?? null, + darkPointerEvents: darkStyle?.pointerEvents ?? null, + menuBackground: headerStyle?.backgroundColor ?? null, + cursorNames, + hasStackCards: [...document.querySelectorAll('[role="tab"]')].length >= 4, + }; + }); +} + +function readDeckButtons(page) { + return page.evaluate(() => + [...document.querySelectorAll('[role="tab"]')] + .filter((el) => el.id.includes('stack')) + .slice(0, 2) + .map((button) => { + const style = getComputedStyle(button); + return [ + style.backgroundColor, + style.backgroundImage, + style.borderColor, + style.maxWidth, + style.color, + ].join(' | '); + }), + ); +} + +function parseInsetTop(clipPath) { + const match = /inset\(([\d.]+)%/.exec(clipPath ?? ''); + return match ? Number(match[1]) : null; +} + +const oldPage = await openHero(OLD_URL); +const newPage = await openHero(NEW_URL); + +const oldGeometry = await trackGeometry(oldPage); +const newGeometry = await trackGeometry(newPage); + +if (!oldGeometry || !newGeometry) { + fail( + 'scroll track present on both sites', + `old=${Boolean(oldGeometry)} new=${Boolean(newGeometry)}`, + ); +} else { + ok('scroll track present on both sites'); + + // scrollProgress -> expected morph via the shared smoothstep law. + const CHECKPOINTS = [0, 0.1375, 0.275, 0.41, 0.6]; + + // Checkpoints are inherently sequential: scroll, settle, read. + for (const progress of CHECKPOINTS) { + // eslint-disable-next-line no-await-in-loop + await scrollToProgress(oldPage, oldGeometry, progress); + // eslint-disable-next-line no-await-in-loop + await scrollToProgress(newPage, newGeometry, progress); + + // eslint-disable-next-line no-await-in-loop + const oldState = await readHeroState(oldPage); + // eslint-disable-next-line no-await-in-loop + const newState = await readHeroState(newPage); + + const oldInset = parseInsetTop(oldState.clipPath); + const newInset = parseInsetTop(newState.clipPath); + + if ( + oldInset !== null && + newInset !== null && + Math.abs(oldInset - newInset) <= 1.5 + ) { + ok( + `wipe position matches at progress ${progress}`, + `old ${oldInset?.toFixed(1)}% vs new ${newInset?.toFixed(1)}%`, + ); + } else { + fail( + `wipe position matches at progress ${progress}`, + `old ${oldState.clipPath} vs new ${newState.clipPath}`, + ); + } + + if (oldState.menuBackground === newState.menuBackground) { + ok( + `menu background matches at progress ${progress}`, + newState.menuBackground, + ); + } else { + fail( + `menu background matches at progress ${progress}`, + `old ${oldState.menuBackground} vs new ${newState.menuBackground}`, + ); + } + + if (oldState.darkPointerEvents === newState.darkPointerEvents) { + ok( + `AI layer interactivity matches at progress ${progress}`, + newState.darkPointerEvents, + ); + } else { + fail( + `AI layer interactivity matches at progress ${progress}`, + `old ${oldState.darkPointerEvents} vs new ${newState.darkPointerEvents}`, + ); + } + } + + // At rest: the collaborative cursors tour the intro on both sites. + await scrollToProgress(oldPage, oldGeometry, 0); + await scrollToProgress(newPage, newGeometry, 0); + const oldRest = await readHeroState(oldPage); + const newRest = await readHeroState(newPage); + + if (oldRest.cursorNames.length === 3 && newRest.cursorNames.length === 3) { + ok('three collaborator cursors at rest on both sites'); + } else { + fail( + 'three collaborator cursors at rest on both sites', + `old ${oldRest.cursorNames.join(',')} vs new ${newRest.cursorNames.join(',')}`, + ); + } + + if (oldRest.darkBackground === newRest.darkBackground) { + ok('dark layer surface matches', newRest.darkBackground); + } else { + fail( + 'dark layer surface matches', + `old ${oldRest.darkBackground} vs new ${newRest.darkBackground}`, + ); + } + + // Fully morphed: playback starts (agent steps / streamed copy appear). + await scrollToProgress(newPage, newGeometry, 0.6); + await newPage.waitForTimeout(4500); + // By 4.5s the steps have collapsed behind the summary and the answer + // streams: either trace proves playback ran. + const playbackStarted = await newPage.evaluate( + () => + document.body.textContent.includes('Organized your open deals') || + document.body.textContent.includes('3 steps') || + document.body.textContent.includes('Read 24 deals'), + ); + if (playbackStarted) { + ok('AI playback runs once fully morphed (first scene steps complete)'); + } else { + fail('AI playback runs once fully morphed', 'no completed step copy found'); + } + + if (newRest.hasStackCards) { + ok('stacked tab deck renders four tabs'); + } else { + fail('stacked tab deck renders four tabs', 'fewer than 4 role=tab'); + } + + // Deck button chrome (the card override the user caught regressing): + // compared at full morph for the active and first inactive tab. + await scrollToProgress(oldPage, oldGeometry, 0.7); + await scrollToProgress(newPage, newGeometry, 0.7); + const oldDeck = await readDeckButtons(oldPage); + const newDeck = await readDeckButtons(newPage); + if (oldDeck.length === 2 && oldDeck.join('\n') === newDeck.join('\n')) { + ok('deck tab button chrome byte-equal (active + inactive)'); + } else { + fail( + 'deck tab button chrome byte-equal (active + inactive)', + `old ${JSON.stringify(oldDeck)} vs new ${JSON.stringify(newDeck)}`, + ); + } + + // RATIFIED DIVERGENCE (user, 2026-06-12): the exit crossing hands off + // transparently like the entry; the old site faded through greys that + // matched neither surface. Asserting the difference keeps it deliberate. + const exitProgress = + (oldGeometry.height - VIEWPORT.height + (VIEWPORT.height - 32)) / + (oldGeometry.height - VIEWPORT.height); + await scrollToProgress(oldPage, oldGeometry, exitProgress); + await scrollToProgress(newPage, newGeometry, exitProgress); + const oldExit = await readHeroState(oldPage); + const newExit = await readHeroState(newPage); + const newExitTransparent = + newExit.menuBackground === 'rgba(0, 0, 0, 0)' || + newExit.menuBackground === 'transparent'; + if (newExitTransparent && newExit.menuBackground !== oldExit.menuBackground) { + ok( + 'exit crossing hands off transparently (ledgered divergence)', + `old ${oldExit.menuBackground} vs new ${newExit.menuBackground}`, + ); + } else { + fail( + 'exit crossing hands off transparently (ledgered divergence)', + `old ${oldExit.menuBackground} vs new ${newExit.menuBackground}`, + ); + } +} + +await browser.close(); + +if (failures.length > 0) { + console.error(`product-hero-parity: FAILED (${failures.length})`); + process.exitCode = 1; +} else { + console.log('product-hero-parity: OK'); +} diff --git a/packages/twenty-website-redone/scripts/product-stepper-parity.mjs b/packages/twenty-website-redone/scripts/product-stepper-parity.mjs new file mode 100644 index 0000000000..c2e55b4108 --- /dev/null +++ b/packages/twenty-website-redone/scripts/product-stepper-parity.mjs @@ -0,0 +1,322 @@ +import { + createBattery, + launchBrowser, + NEW_BASE, + OLD_BASE, + openPage, +} from './battery-kit.mjs'; + +// A/B battery for the product stepper: section chrome, the sticky scroll +// choreography at pinned positions, the visual crossfade, and the three +// interactive editors (entity drag, workflow beat, layout toggle+reorder). + +const HEADING = 'Go the extra mile'; + +const { compare, fail, finish, ok } = createBattery('product-stepper-parity'); + +// The old site tints the stepper section with 5% black over white; ours +// bakes the same color as a solid token. Composite before comparing. +const compositeOverWhite = (cssColor) => { + const match = /rgba?\(([\d.]+), ([\d.]+), ([\d.]+)(?:, ([\d.]+))?\)/.exec( + cssColor ?? '', + ); + if (!match) return cssColor; + const alpha = match[4] === undefined ? 1 : Number(match[4]); + const channel = (value) => + Math.round(Number(value) * alpha + 255 * (1 - alpha)); + return `rgb(${channel(match[1])}, ${channel(match[2])}, ${channel(match[3])})`; +}; + +const browser = await launchBrowser(); + +// Scrolls the stepper section to a fraction of its own scrollable track. +async function scrollSectionTo(page, trackFraction) { + await page.evaluate( + ({ needle, fraction }) => { + const heading = [...document.querySelectorAll('h2')].find((el) => + el.textContent.includes(needle), + ); + const section = heading?.closest('section'); + if (!section) return; + const rect = section.getBoundingClientRect(); + const top = rect.top + window.scrollY; + const scrollable = section.offsetHeight - window.innerHeight; + window.scrollTo(0, top + fraction * Math.max(scrollable, 0)); + }, + { needle: HEADING, fraction: trackFraction }, + ); + await page.waitForTimeout(700); +} + +function readStepperState(page) { + return page.evaluate((needle) => { + const heading = [...document.querySelectorAll('h2')].find((el) => + el.textContent.includes(needle), + ); + const section = heading?.closest('section'); + if (!section) return null; + const sectionStyle = getComputedStyle(section); + const headingStyle = getComputedStyle(heading); + + // Step rows: each holds a 22px icon box; read its block's inline + // opacity/transform vars or styles. + const stepLabels = ['Data model', 'Automation', 'Layout']; + const steps = stepLabels.map((label) => { + const labelNode = [...section.querySelectorAll('span, h3, div')].find( + (el) => el.childElementCount === 0 && el.textContent.trim() === label, + ); + let block = labelNode; + while (block && block.parentElement !== null) { + const style = getComputedStyle(block); + if (style.transitionProperty.includes('opacity')) break; + block = block.parentElement; + } + const blockStyle = block ? getComputedStyle(block) : null; + const iconBox = labelNode?.parentElement?.querySelector('div'); + return { + opacity: blockStyle ? Number(blockStyle.opacity).toFixed(2) : null, + moving: blockStyle ? blockStyle.transform !== 'none' : null, + iconBoxBackground: iconBox + ? getComputedStyle(iconBox).backgroundColor + : null, + }; + }); + + // The visual slides: absolute crossfading layers inside the frame. + const slides = [...section.querySelectorAll('div')].filter((el) => { + const style = getComputedStyle(el); + return ( + style.position === 'absolute' && + style.transitionProperty.includes('opacity') && + style.transitionDuration.includes('0.4s') && + el.querySelector('svg') + ); + }); + + const frame = [...section.querySelectorAll('div')].find((el) => { + const ratio = el.offsetWidth / el.offsetHeight; + return el.querySelector('img') && Math.abs(ratio - 672 / 705) < 0.01; + }); + const frameImages = frame + ? [...frame.querySelectorAll('img')].map((img) => img.naturalWidth > 0) + : []; + + return { + sectionBackground: sectionStyle.backgroundColor, + headingStyle: `${headingStyle.fontSize} ${headingStyle.fontWeight}`, + steps, + slideOpacities: slides.map((el) => Number(getComputedStyle(el).opacity)), + frameAspectOk: Boolean(frame), + frameImages, + }; + }, HEADING); +} + +// Finds a draggable card by its label inside the active slide and returns +// its center plus the connector layer's path data for follow checks. +function readDraggable(page, labelText) { + return page.evaluate((label) => { + const node = [...document.querySelectorAll('span, div')].find( + (el) => el.childElementCount === 0 && el.textContent.trim() === label, + ); + let card = node; + while (card) { + const style = getComputedStyle(card); + if (style.position === 'absolute' && style.cursor === 'grab') break; + card = card.parentElement; + } + if (!card) return null; + const rect = card.getBoundingClientRect(); + const svg = card.parentElement?.querySelector('svg'); + return { + x: rect.x + rect.width / 2, + y: rect.y + 10, + left: rect.x, + paths: svg + ? [...svg.querySelectorAll('path')].map((p) => p.getAttribute('d')) + : [], + }; + }, labelText); +} + +const oldPage = await openPage(browser, `${OLD_BASE}/product`); +const newPage = await openPage(browser, `${NEW_BASE}/product`); +const pages = [ + ['old', oldPage], + ['new', newPage], +]; + +// --- Step 1: section chrome + initial choreography --------------------- +await scrollSectionTo(oldPage, 0.05); +await scrollSectionTo(newPage, 0.05); +const oldStart = await readStepperState(oldPage); +const newStart = await readStepperState(newPage); + +if (!oldStart || !newStart) { + fail( + 'stepper present on both sites', + `old=${Boolean(oldStart)} new=${Boolean(newStart)}`, + ); +} else { + compare( + 'section background (composited)', + compositeOverWhite(oldStart.sectionBackground), + compositeOverWhite(newStart.sectionBackground), + ); + compare('heading style', oldStart.headingStyle, newStart.headingStyle); + compare( + 'step choreography at start', + oldStart.steps.map((step) => `${step.opacity}|${step.moving}`), + newStart.steps.map((step) => `${step.opacity}|${step.moving}`), + ); + compare( + 'active step icon box ink', + oldStart.steps[0].iconBoxBackground, + newStart.steps[0].iconBoxBackground, + ); + compare( + 'first slide active, rest hidden', + oldStart.slideOpacities, + newStart.slideOpacities, + ); + if (newStart.frameAspectOk && newStart.frameImages.every(Boolean)) { + ok('frame holds 672/705 with both artwork images loaded'); + } else { + fail( + 'frame holds 672/705 with both artwork images loaded', + JSON.stringify(newStart), + ); + } +} + +// --- Mid-track: second step takes over ---------------------------------- +await scrollSectionTo(oldPage, 0.5); +await scrollSectionTo(newPage, 0.5); +const oldMid = await readStepperState(oldPage); +const newMid = await readStepperState(newPage); +compare( + 'step choreography mid-track', + oldMid.steps.map((step) => `${step.opacity}|${step.moving}`), + newMid.steps.map((step) => `${step.opacity}|${step.moving}`), +); +compare( + 'second slide active mid-track', + oldMid.slideOpacities, + newMid.slideOpacities, +); + +// Workflow beat: while the second slide is active, check badges tick in. +for (const [name, page] of pages) { + // eslint-disable-next-line no-await-in-loop + await page.waitForTimeout(2600); + // eslint-disable-next-line no-await-in-loop + const visibleChecks = await page.evaluate(() => { + return [...document.querySelectorAll('span')].filter((el) => { + const style = getComputedStyle(el); + return ( + style.width === '12px' && + style.height === '12px' && + Number(style.opacity) === 1 && + el.querySelector('svg') + ); + }).length; + }); + if (visibleChecks >= 1) { + ok(`workflow beat ticks check badges on ${name} site`, `${visibleChecks}`); + } else { + fail(`workflow beat ticks check badges on ${name} site`, '0 visible'); + } +} + +// --- Back to step 1: entity drag + edges follow ------------------------- +for (const [name, page] of pages) { + // eslint-disable-next-line no-await-in-loop + await scrollSectionTo(page, 0.05); + // eslint-disable-next-line no-await-in-loop + await page.waitForTimeout(600); + // eslint-disable-next-line no-await-in-loop + const before = await readDraggable(page, 'Workspaces'); + if (!before) { + fail(`entity card found on ${name} site`, 'no Workspaces card'); + continue; + } + // eslint-disable-next-line no-await-in-loop + await page.mouse.move(before.x, before.y); + // eslint-disable-next-line no-await-in-loop + await page.mouse.down(); + for (const step of [10, 25, 40]) { + // eslint-disable-next-line no-await-in-loop + await page.mouse.move(before.x + step, before.y + step); + } + // eslint-disable-next-line no-await-in-loop + await page.mouse.up(); + // eslint-disable-next-line no-await-in-loop + const after = await readDraggable(page, 'Workspaces'); + const moved = after && Math.abs(after.left - before.left) >= 30; + const edgesFollowed = + after && JSON.stringify(after.paths) !== JSON.stringify(before.paths); + if (moved && edgesFollowed) { + ok(`entity drag moves card and edges follow on ${name} site`); + } else { + fail( + `entity drag moves card and edges follow on ${name} site`, + `moved=${moved} edges=${edgesFollowed}`, + ); + } +} + +// --- Step 3: layout visibility toggle ------------------------------------ +for (const [name, page] of pages) { + // eslint-disable-next-line no-await-in-loop + await scrollSectionTo(page, 0.85); + // eslint-disable-next-line no-await-in-loop + await page.waitForTimeout(700); + // eslint-disable-next-line no-await-in-loop + const eye = await page.evaluate(() => { + // The hidden ICP field renders the slashed eye (a 3-13 diagonal line). + const slashed = [...document.querySelectorAll('svg')].find((svg) => + [...svg.querySelectorAll('path')].some( + (path) => path.getAttribute('d') === 'M3 3l10 10', + ), + ); + if (!slashed) return null; + const rect = slashed.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }); + if (!eye) { + fail(`hidden-field eye found on ${name} site`, 'no slashed eye glyph'); + continue; + } + // The field row captures the pointer on press (drag affordance), which + // retargets synthesized clicks on both sites — drive the button itself. + // eslint-disable-next-line no-await-in-loop + await page.evaluate(() => { + const slashed = [...document.querySelectorAll('svg')].find((svg) => + [...svg.querySelectorAll('path')].some( + (path) => path.getAttribute('d') === 'M3 3l10 10', + ), + ); + slashed?.closest('span, div')?.click(); + }); + // eslint-disable-next-line no-await-in-loop + await page.waitForTimeout(250); + // eslint-disable-next-line no-await-in-loop + const slashedCount = await page.evaluate( + () => + [...document.querySelectorAll('svg')].filter((svg) => + [...svg.querySelectorAll('path')].some( + (path) => path.getAttribute('d') === 'M3 3l10 10', + ), + ).length, + ); + if (slashedCount === 0) { + ok(`layout eye toggle reveals the hidden field on ${name} site`); + } else { + fail( + `layout eye toggle reveals the hidden field on ${name} site`, + `${slashedCount} slashed eyes remain`, + ); + } +} + +await finish(browser); diff --git a/packages/twenty-website-redone/scripts/three-cards-parity.mjs b/packages/twenty-website-redone/scripts/three-cards-parity.mjs new file mode 100644 index 0000000000..91432194c9 --- /dev/null +++ b/packages/twenty-website-redone/scripts/three-cards-parity.mjs @@ -0,0 +1,261 @@ +import { + createBattery, + launchBrowser, + NEW_BASE, + OLD_BASE, + openPage, +} from './battery-kit.mjs'; + +// A/B battery for the three-cards family: the home section's restored +// scroll choreography (old == new at pinned scroll positions) and the +// product variant's intro, section rhythm, and footerless cards. + +const HOME_HEADING = 'Assemble, iterate and adapt a robust CRM,'; +const PRODUCT_HEADING = 'A modern CRM with'; + +const { compare, fail, finish, ok } = createBattery('three-cards-parity'); + +const browser = await launchBrowser(); + +const openSitePage = (base, path, reducedMotion = false) => + openPage(browser, `${base}${path}`, { reducedMotion, settleMs: 900 }); + +// The grid + its will-change slots, found from the section's heading. +function readSection(page, headingNeedle) { + return page.evaluate((needle) => { + const heading = [...document.querySelectorAll('h2')].find((el) => + el.textContent.includes(needle), + ); + const section = heading?.closest('section'); + if (!section) return null; + + const slots = [...section.querySelectorAll('div')].filter((el) => { + const style = getComputedStyle(el); + return style.willChange.includes('transform') && el.children.length === 1; + }); + const grid = slots[0]?.parentElement; + const gridStyle = grid ? getComputedStyle(grid) : null; + const sectionStyle = getComputedStyle(section); + const containerStyle = grid?.closest('section > *') + ? getComputedStyle(section.children[0]) + : null; + const headingStyle = getComputedStyle(heading); + + const rect = grid?.getBoundingClientRect(); + return { + slotPoses: slots.map((el) => `${el.style.opacity}|${el.style.transform}`), + slotCount: slots.length, + gridTop: rect ? rect.top + window.scrollY : null, + gridColumns: gridStyle + ? `${gridStyle.gridAutoFlow} ${gridStyle.gap}` + : null, + sectionPadding: `${sectionStyle.paddingTop} ${sectionStyle.paddingBottom}`, + containerPadding: containerStyle + ? `${containerStyle.paddingLeft} ${containerStyle.rowGap}` + : null, + headingStyle: `${headingStyle.fontSize} ${headingStyle.fontWeight} ${headingStyle.maxWidth}`, + // Per-card anatomy: an h3, a body, and whether a footer rendered. + cards: slots.map((slot) => ({ + hasHeading: Boolean(slot.querySelector('h3')), + hasFooter: Boolean(slot.querySelector('footer')), + })), + }; + }, headingNeedle); +} + +// Sub-pixel scroll residuals differ between the sites' page heights, so +// mid-travel poses compare numerically, not byte-wise. +const parsePose = (pose) => { + const [opacity, transform] = pose.split('|'); + const translate = /translateY\(([-\d.]+)px\)/.exec(transform); + const scale = /scale\(([-\d.]+)\)/.exec(transform); + return { + opacity: Number(opacity), + translateY: translate ? Number(translate[1]) : null, + scale: scale ? Number(scale[1]) : null, + }; +}; + +async function scrollGridTo(page, gridTop, viewportFraction) { + await page.evaluate( + ({ top, fraction }) => + window.scrollTo(0, Math.max(0, top - window.innerHeight * fraction)), + { top: gridTop, fraction: viewportFraction }, + ); + await page.waitForTimeout(350); +} + +// --- HOME: restored scroll choreography -------------------------------- +{ + const oldPage = await openSitePage(OLD_BASE, '/'); + const newPage = await openSitePage(NEW_BASE, '/'); + + const oldRest = await readSection(oldPage, HOME_HEADING); + const newRest = await readSection(newPage, HOME_HEADING); + + if (!oldRest || !newRest) { + fail( + 'home three-cards present on both sites', + `old=${Boolean(oldRest)} new=${Boolean(newRest)}`, + ); + } else { + compare('home slot count', oldRest.slotCount, newRest.slotCount); + compare( + 'home initial card poses (grid off-screen)', + oldRest.slotPoses, + newRest.slotPoses, + ); + + // Mid-travel: grid top at 60% of the viewport → progress 0.5. + await scrollGridTo(oldPage, oldRest.gridTop, 0.6); + await scrollGridTo(newPage, newRest.gridTop, 0.6); + const oldMid = await readSection(oldPage, HOME_HEADING); + const newMid = await readSection(newPage, HOME_HEADING); + const midMatches = oldMid.slotPoses.every((oldPose, poseNumber) => { + const a = parsePose(oldPose); + const b = parsePose(newMid.slotPoses[poseNumber] ?? ''); + return ( + Math.abs(a.opacity - b.opacity) <= 0.02 && + a.translateY !== null && + b.translateY !== null && + Math.abs(a.translateY - b.translateY) <= 2 && + Math.abs(a.scale - b.scale) <= 0.002 + ); + }); + if (midMatches) { + ok('home mid-scroll card poses match within tolerance'); + } else { + fail( + 'home mid-scroll card poses match within tolerance', + `old ${JSON.stringify(oldMid.slotPoses)} vs new ${JSON.stringify(newMid.slotPoses)}`, + ); + } + const midMoving = newMid.slotPoses.some( + (pose) => !pose.startsWith('1|') || pose.includes('translateY'), + ); + if (midMoving) { + ok('home cards are mid-travel at the checkpoint'); + } else { + fail( + 'home cards are mid-travel at the checkpoint', + JSON.stringify(newMid.slotPoses), + ); + } + + // Settled: grid top at 10% of the viewport → full progress. + await scrollGridTo(oldPage, oldRest.gridTop, 0.1); + await scrollGridTo(newPage, newRest.gridTop, 0.1); + const oldDone = await readSection(oldPage, HOME_HEADING); + const newDone = await readSection(newPage, HOME_HEADING); + compare( + 'home settled card poses byte-equal', + oldDone.slotPoses, + newDone.slotPoses, + ); + compare('home settled pose value', newDone.slotPoses, [ + '1|translateY(0px) scale(1)', + '1|translateY(0px) scale(1)', + '1|translateY(0px) scale(1)', + ]); + + compare( + 'home cards keep attribution footers', + newDone.cards.map((card) => card.hasFooter), + [true, true, true], + ); + } + + await oldPage.close(); + await newPage.close(); + + // RATIFIED DIVERGENCE: reduced motion settles the cards; the old site + // animates regardless. Asserting the difference keeps it deliberate. + const oldReduced = await openSitePage(OLD_BASE, '/', true); + const newReduced = await openSitePage(NEW_BASE, '/', true); + const oldReducedRest = await readSection(oldReduced, HOME_HEADING); + const newReducedRest = await readSection(newReduced, HOME_HEADING); + await scrollGridTo(oldReduced, oldReducedRest.gridTop, 0.6); + await scrollGridTo(newReduced, newReducedRest.gridTop, 0.6); + const oldReducedMid = await readSection(oldReduced, HOME_HEADING); + const newReducedMid = await readSection(newReduced, HOME_HEADING); + const newSettled = newReducedMid.slotPoses.every((pose) => pose === '1|none'); + const divergesFromOld = + JSON.stringify(oldReducedMid.slotPoses) !== + JSON.stringify(newReducedMid.slotPoses); + if (newSettled && divergesFromOld) { + ok('reduced motion settles cards (ledgered divergence from old)'); + } else { + fail( + 'reduced motion settles cards (ledgered divergence from old)', + `new ${JSON.stringify(newReducedMid.slotPoses)} old ${JSON.stringify(oldReducedMid.slotPoses)}`, + ); + } + await oldReduced.close(); + await newReduced.close(); +} + +// --- PRODUCT: intro, rhythm, footerless cards --------------------------- +{ + const oldPage = await openSitePage(OLD_BASE, '/product'); + const newPage = await openSitePage(NEW_BASE, '/product'); + + const oldSection = await readSection(oldPage, PRODUCT_HEADING); + const newSection = await readSection(newPage, PRODUCT_HEADING); + + // The rhythm system owns section padding universally (ratified standard: + // the old site padded each section's inner container ad hoc) — the + // product section must carry exactly the home section's rhythm. + const newHomePage = await openSitePage(NEW_BASE, '/'); + const newHomeSection = await readSection(newHomePage, HOME_HEADING); + compare( + 'product section rhythm equals home section rhythm (universal padding)', + newHomeSection?.sectionPadding, + newSection?.sectionPadding, + ); + await newHomePage.close(); + + if (!oldSection || !newSection) { + fail( + 'product three-cards present on both sites', + `old=${Boolean(oldSection)} new=${Boolean(newSection)}`, + ); + } else { + compare('product slot count', oldSection.slotCount, newSection.slotCount); + compare( + 'product grid flow and gap', + oldSection.gridColumns, + newSection.gridColumns, + ); + compare( + 'product heading style + measure', + oldSection.headingStyle, + newSection.headingStyle, + ); + compare( + 'product cards carry no footer', + newSection.cards.map((card) => card.hasFooter), + [false, false, false], + ); + compare( + 'product card anatomy matches old', + oldSection.cards, + newSection.cards, + ); + + // Settled choreography on product too (same shared machinery). + await scrollGridTo(oldPage, oldSection.gridTop, 0.1); + await scrollGridTo(newPage, newSection.gridTop, 0.1); + const oldDone = await readSection(oldPage, PRODUCT_HEADING); + const newDone = await readSection(newPage, PRODUCT_HEADING); + compare( + 'product settled card poses byte-equal', + oldDone.slotPoses, + newDone.slotPoses, + ); + } + + await oldPage.close(); + await newPage.close(); +} + +await finish(browser); diff --git a/packages/twenty-website-redone/scripts/visual-battery.mjs b/packages/twenty-website-redone/scripts/visual-battery.mjs new file mode 100644 index 0000000000..2838ace63f --- /dev/null +++ b/packages/twenty-website-redone/scripts/visual-battery.mjs @@ -0,0 +1,629 @@ +import { chromium } from 'playwright'; + +import { PENDING_VISUAL_SLOTS } from './pending-visual-slots.mjs'; +import sharp from 'sharp'; + +// The WebGL verification battery. Dot patterns are not pixel-comparable +// across runs (old-vs-old fails pixel identity), so the grammar is: box, +// coverage, dominant-hue class, liveliness, lifecycle — with thresholds +// calibrated against noise, never assumed. +// +// Usage: node scripts/visual-battery.mjs [visualKey ...] (default: all) + +const BASE_URL = process.env.VISUAL_BATTERY_URL ?? 'http://localhost:3004/'; +const VIEWPORT = { width: 1440, height: 900 }; + +const VISUALS = { + hourglass: { + slotSelector: '[data-illustration="hourglass"]', + // blue #4a38f5 + hueRangeDegrees: [200, 260], + minCoverage: 0.02, + animated: true, + interactive: false, + }, + 'hero-bridge': { + slotSelector: '[data-illustration="hero-bridge"]', + // blue dashes from the bridge's dark areas; hover light is the + // interactivity; priority mount at the very top of the page + hueRangeDegrees: [200, 260], + minCoverage: 0.01, + animated: false, + interactive: true, + // the hero center is the copy block, which opts out of hover via + // [data-halftone-exclude] — probe the artwork's left field instead + interactionPoint: [0.06, 0.72], + // the hover light brightens only the dashes inside its 158px circle: + // ~0.3% of canvas pixels at full effect (reduced baseline is 0.00%) + interactionFloor: 0.002, + }, + 'footer-backdrop': { + slotSelector: '[data-illustration="footer-backdrop"]', + // charcoal on black: hueless and subtle — breathe is the liveliness + hueRangeDegrees: null, + minCoverage: 0.005, + animated: true, + // charcoal-on-black breathe is subtle; its reduced-motion baseline + // measures 0.00%, so 0.1% is unambiguous motion. + motionFloor: 0.001, + interactive: false, + }, + 'stepper-backdrop': { + slotSelector: '[data-illustration="stepper-backdrop"]', + // fog gray: hueless; hover (pointer move) is the interactivity proof + hueRangeDegrees: null, + minCoverage: 0.02, + animated: false, + interactive: true, + }, + faq: { + slotSelector: '[data-illustration="faq"]', + // blue rows, rotate-only, no pointer + hueRangeDegrees: [200, 260], + minCoverage: 0.005, + animated: true, + interactive: false, + }, + monolith: { + slotSelector: '[data-illustration="monolith"]', + // ash gray: hueless — coverage + hover-light interactivity only + hueRangeDegrees: null, + minCoverage: 0.02, + animated: false, + interactive: true, + }, + diamond: { + slotSelector: '[data-illustration="diamond"]', + // blue on the white stage + hueRangeDegrees: [200, 260], + minCoverage: 0.01, + animated: true, + interactive: true, + settle: { cardReveal: true }, + }, + flash: { + slotSelector: '[data-illustration="flash"]', + hueRangeDegrees: [200, 260], + minCoverage: 0.01, + animated: true, + // The thin bolt rotates near edge-on at the sampling phase (the card + // reveal shifted mount timing); ~0.25% diff vs a 0.00% reduced + // baseline is unambiguous motion. + motionFloor: 0.002, + interactive: true, + settle: { cardReveal: true }, + }, + lock: { + slotSelector: '[data-illustration="lock"]', + hueRangeDegrees: [200, 260], + minCoverage: 0.01, + animated: true, + interactive: true, + settle: { cardReveal: true }, + }, + target: { + slotSelector: '[data-illustration="target"]', + // pink #ed87fc + hueRangeDegrees: [270, 330], + minCoverage: 0.01, + // breathe-only: passive motion is sub-pixel; drag is the liveliness proof + animated: false, + interactive: true, + settle: { stageProgress: 0.3 }, + }, + spaceship: { + slotSelector: '[data-illustration="spaceship"]', + // green #89fc9a + hueRangeDegrees: [95, 160], + minCoverage: 0.01, + animated: false, + interactive: true, + settle: { stageProgress: 0.6 }, + }, + money: { + slotSelector: '[data-illustration="money"]', + // yellow #feffb7 (near-white: low saturation tolerated) + hueRangeDegrees: [40, 80], + minCoverage: 0.005, + minSaturation: 0.12, + animated: false, + interactive: true, + settle: { stageProgress: 0.9 }, + }, + + 'fast-path': { + slotSelector: '[data-illustration="fast-path"]', + // the white command palette over the dark dash backdrop; mixed + // palette so hue is skipped. Idle is static; the drag probe's click + // on a command fires the confetti burst plus the hover lift. + hueRangeDegrees: null, + minCoverage: 0.02, + animated: false, + interactive: true, + }, + 'live-data': { + slotSelector: '[data-illustration="live-data"]', + // the white companies panel over the dark dash backdrop with the + // collaborator markers; mixed palette so hue is skipped. Idle is + // static; HOVERING runs the whole two-actor demo (tag rename, filter + // pop-away, rows arriving), so the probe's pointer presence is the + // liveliness. + hueRangeDegrees: null, + minCoverage: 0.02, + animated: false, + interactive: true, + }, + 'familiar-interface': { + slotSelector: '[data-illustration="familiar-interface"]', + // the white opportunity board over the dark dash backdrop; the + // palette is mixed (pink/purple pills, blue active card) so hue is + // skipped. Idle is static; the drag probe grabs a card (and retires + // the hand-cursor affordance), so interaction is the liveliness. + hueRangeDegrees: null, + minCoverage: 0.02, + animated: false, + interactive: true, + }, + + // The product page's three-cards models — same grammar as the home + // trio (blue band halftone on the white stage, auto-rotate + drag). + speed: { + slotSelector: '[data-illustration="speed"]', + path: '/product', + hueRangeDegrees: [200, 260], + minCoverage: 0.01, + animated: true, + interactive: true, + settle: { cardReveal: true }, + }, + eye: { + slotSelector: '[data-illustration="eye"]', + path: '/product', + hueRangeDegrees: [200, 260], + minCoverage: 0.01, + animated: true, + interactive: true, + settle: { cardReveal: true }, + }, + singleScreen: { + slotSelector: '[data-illustration="singleScreen"]', + path: '/product', + hueRangeDegrees: [200, 260], + minCoverage: 0.01, + animated: true, + interactive: true, + settle: { cardReveal: true }, + }, + // The partner hero halftone: stone dashes (#959595) at rest are hueless; + // the cursor turns nearby dashes blue and shifts the band (interactive). + 'partner-hero': { + slotSelector: '[data-illustration="partner-hero"]', + path: '/partners', + hueRangeDegrees: null, + minCoverage: 0.02, + animated: false, + interactive: true, + }, + // The promo mic: iron dashes (#777) at rest are hueless on the light panel; + // the hover light brightens the dashes near the cursor (interactive). + 'promo-mic': { + slotSelector: '[data-illustration="promo-mic"]', + path: '/partners', + hueRangeDegrees: null, + minCoverage: 0.02, + animated: false, + interactive: true, + }, + // The testimonial author portrait: white dashes (hueless) on the dark panel; + // the cursor shifts the band and adds a light (interactive). + 'partner-portrait': { + slotSelector: '[data-illustration="partner-portrait"]', + path: '/partners', + hueRangeDegrees: null, + minCoverage: 0.02, + animated: false, + interactive: true, + }, + // The decorative quote-mark GLB behind the text: blue (#4a38f5) band + // halftone with a slow breathe; desktop-only + aria-hidden. + 'partner-quote': { + slotSelector: '[data-illustration="partner-quote"]', + path: '/partners', + hueRangeDegrees: [200, 260], + minCoverage: 0.01, + animated: false, + interactive: false, + }, +}; + +const MOTION_DIFF_FLOOR = 0.005; +const LOAD_TIMEOUT_MS = 30000; + +const failures = []; + +// Every data-illustration slot on the page must carry a battery spec — +// a new visual without coverage is a failure, not a silent gap. +async function assertSpecCompleteness(page, specNames) { + const mounted = await page.evaluate(() => + [...document.querySelectorAll('[data-illustration]')].map((el) => + el.getAttribute('data-illustration'), + ), + ); + const uncovered = mounted.filter( + (name) => !specNames.has(name) && !PENDING_VISUAL_SLOTS.has(name), + ); + const pendingOnPage = mounted.filter((name) => + PENDING_VISUAL_SLOTS.has(name), + ); + if (pendingOnPage.length > 0) { + console.log( + ` - pending (declared, no spec yet): ${pendingOnPage.join(', ')}`, + ); + } + if (uncovered.length > 0) { + console.error(` ✗ slots without battery specs: ${uncovered.join(', ')}`); + failures.push(`uncovered slots: ${uncovered.join(', ')}`); + } else { + console.log(` ✓ all ${mounted.length} mounted slots carry specs`); + } +} +const assert = (condition, message) => { + if (condition) { + console.log(` ✓ ${message}`); + } else { + failures.push(message); + console.error(` ✗ ${message}`); + } +}; + +const readClip = async (page, box) => { + const buffer = await page.screenshot({ + clip: { + x: Math.max(0, box.x), + y: Math.max(0, box.y), + width: Math.max(1, box.width), + height: Math.max(1, box.height), + }, + }); + return sharp(buffer).raw().toBuffer({ resolveWithObject: true }); +}; + +const rgbToHueSaturation = (r, g, b) => { + const max = Math.max(r, g, b) / 255; + const min = Math.min(r, g, b) / 255; + const delta = max - min; + let hue = 0; + if (delta > 0) { + if (max === r / 255) hue = (((g - b) / 255 / delta) % 6) * 60; + else if (max === g / 255) hue = ((b - r) / 255 / delta + 2) * 60; + else hue = ((r - g) / 255 / delta + 4) * 60; + } + if (hue < 0) hue += 360; + const saturation = max === 0 ? 0 : delta / max; + return { hue, saturation }; +}; + +const analyzeClip = ( + { data, info }, + backgroundSample, + minSaturation = 0.25, +) => { + let foreground = 0; + let hueWeightedSum = 0; + let hueSamples = 0; + const total = info.width * info.height; + for (let i = 0; i < total; i += 1) { + const offset = i * info.channels; + const r = data[offset]; + const g = data[offset + 1]; + const b = data[offset + 2]; + const isBackground = + Math.abs(r - backgroundSample[0]) <= 12 && + Math.abs(g - backgroundSample[1]) <= 12 && + Math.abs(b - backgroundSample[2]) <= 12; + if (isBackground) continue; + foreground += 1; + const { hue, saturation } = rgbToHueSaturation(r, g, b); + if (saturation > minSaturation) { + hueWeightedSum += hue; + hueSamples += 1; + } + } + return { + coverage: foreground / total, + dominantHue: hueSamples > 0 ? hueWeightedSum / hueSamples : null, + }; +}; + +const diffClips = (a, b) => { + const total = Math.min(a.data.length, b.data.length); + let changed = 0; + let compared = 0; + for (let offset = 0; offset < total; offset += a.info.channels) { + compared += 1; + if ( + Math.abs(a.data[offset] - b.data[offset]) > 8 || + Math.abs(a.data[offset + 1] - b.data[offset + 1]) > 8 || + Math.abs(a.data[offset + 2] - b.data[offset + 2]) > 8 + ) { + changed += 1; + } + } + return changed / compared; +}; + +const cornerSample = ({ data }) => [data[0], data[1], data[2]]; + +const waitForLoadedCanvas = async (page, slotSelector) => { + const deadline = Date.now() + LOAD_TIMEOUT_MS; + // Polling: sequential await is the semantics, not an oversight. + while (Date.now() < deadline) { + // eslint-disable-next-line no-await-in-loop + const box = await page + .locator(`${slotSelector} canvas`) + .first() + .boundingBox() + .catch(() => null); + if (box) { + // eslint-disable-next-line no-await-in-loop + const clip = await readClip(page, box); + const sample = cornerSample(clip); + const { coverage } = analyzeClip(clip, sample); + if (coverage > 0.001) { + return box; + } + } + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return null; +}; + +const settleForSpec = async (page, spec) => { + if (spec.settle?.stageProgress !== undefined) { + // The helped cards travel a 280vh fan; each card is readable only in + // its own hold window of the stage progress. + await page.evaluate((progress) => { + const stage = document.querySelector('#homepage-cases'); + if (stage) { + const rect = stage.getBoundingClientRect(); + const top = rect.top + window.scrollY; + const scrollable = rect.height - window.innerHeight; + window.scrollTo(0, top + progress * scrollable); + } + }, spec.settle.stageProgress); + await page.waitForTimeout(800); + return; + } + if (spec.settle?.cardReveal) { + // The three-cards grid rides the scroll-driven reveal; park the slot + // high enough that the choreography has fully settled before probing. + await page.evaluate((selector) => { + const slot = document.querySelector(selector); + if (slot) { + const rect = slot.getBoundingClientRect(); + window.scrollTo( + 0, + rect.top + window.scrollY - window.innerHeight * 0.15, + ); + } + }, spec.slotSelector); + await page.waitForTimeout(900); + return; + } + await page.locator(spec.slotSelector).first().scrollIntoViewIfNeeded(); +}; + +const runVisual = async (browser, key, spec) => { + console.log(`── ${key}`); + const page = await browser.newPage({ + viewport: VIEWPORT, + deviceScaleFactor: 1, + }); + await page.goto(new URL(spec.path ?? '', BASE_URL).href, { + waitUntil: 'networkidle', + timeout: 180000, + }); + + const slot = page.locator(spec.slotSelector).first(); + await settleForSpec(page, spec); + const slotBox = await slot.boundingBox(); + assert(slotBox !== null, 'slot exists'); + + const canvasBox = await waitForLoadedCanvas(page, spec.slotSelector); + assert( + canvasBox !== null, + 'canvas renders non-uniform pixels before timeout', + ); + if (canvasBox === null) { + await page.close(); + return; + } + + const sizeMatches = + Math.abs(canvasBox.width - slotBox.width) <= 2 && + canvasBox.height <= slotBox.height + 2; + assert( + sizeMatches, + `canvas box ≈ slot box (${Math.round(canvasBox.width)}x${Math.round(canvasBox.height)} in ${Math.round(slotBox.width)}x${Math.round(slotBox.height)})`, + ); + + const clipA = await readClip(page, canvasBox); + const background = cornerSample(clipA); + const { coverage, dominantHue } = analyzeClip( + clipA, + background, + spec.minSaturation, + ); + assert( + coverage >= spec.minCoverage, + `coverage ${(coverage * 100).toFixed(1)}% ≥ ${spec.minCoverage * 100}%`, + ); + if (spec.hueRangeDegrees === null) { + console.log(' - hue skipped (hueless artwork)'); + } else if (dominantHue !== null) { + assert( + dominantHue >= spec.hueRangeDegrees[0] && + dominantHue <= spec.hueRangeDegrees[1], + `dominant hue ${Math.round(dominantHue)}° within [${spec.hueRangeDegrees}]`, + ); + } else { + assert(false, 'saturated pixels present for hue measurement'); + } + + if (spec.animated) { + await page.waitForTimeout(400); + const clipB = await readClip(page, canvasBox); + const motionDiff = diffClips(clipA, clipB); + assert( + motionDiff > (spec.motionFloor ?? MOTION_DIFF_FLOOR), + `motion alive (diff ${(motionDiff * 100).toFixed(2)}%)`, + ); + } + + if (spec.interactive) { + // Bleed canvases extend past the viewport: interaction fractions + // resolve against the VISIBLE region or the pointer hits nothing. + const visibleLeft = Math.max(canvasBox.x, 0); + const visibleTop = Math.max(canvasBox.y, 0); + const visibleWidth = + Math.min(canvasBox.x + canvasBox.width, VIEWPORT.width) - visibleLeft; + const visibleHeight = + Math.min(canvasBox.y + canvasBox.height, VIEWPORT.height) - visibleTop; + const [fractionX, fractionY] = spec.interactionPoint ?? [0.5, 0.5]; + const centerX = visibleLeft + visibleWidth * fractionX; + const centerY = visibleTop + visibleHeight * fractionY; + const before = await readClip(page, canvasBox); + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + for (let step = 1; step <= 5; step += 1) { + // eslint-disable-next-line no-await-in-loop + await page.mouse.move(centerX + step * 14, centerY + step * 6); + } + await page.mouse.up(); + await page.waitForTimeout(250); + const after = await readClip(page, canvasBox); + const dragDiff = diffClips(before, after); + assert( + dragDiff > (spec.interactionFloor ?? MOTION_DIFF_FLOOR), + `drag changes pixels (diff ${(dragDiff * 100).toFixed(2)}%)`, + ); + } + + // Lifecycle: scroll far away, wait past the dispose grace, expect release. + const counts = () => + page.evaluate( + () => window.__visualRuntimeTest?.getActiveContextCount() ?? -1, + ); + const activeWhileVisible = await counts(); + assert( + activeWhileVisible >= 1, + `context held while visible (count ${activeWhileVisible})`, + ); + // Leave toward whichever page end is farther, so slots near the top + // actually exit the generous mount margins. + await page.evaluate((selector) => { + const slotElement = document.querySelector(selector); + const slotCenter = + slotElement.getBoundingClientRect().top + + window.scrollY + + slotElement.getBoundingClientRect().height / 2; + const pageHeight = document.documentElement.scrollHeight; + window.scrollTo(0, slotCenter < pageHeight / 2 ? pageHeight : 0); + }, spec.slotSelector); + await page.waitForTimeout(5500); + const canvasAfterLeave = await page + .locator(`${spec.slotSelector} canvas`) + .count(); + assert( + canvasAfterLeave === 0, + `scene unmounted after leaving (canvases ${canvasAfterLeave}, global count ${await counts()})`, + ); + await settleForSpec(page, spec); + const reacquired = await waitForLoadedCanvas(page, spec.slotSelector); + assert(reacquired !== null, 'scene re-acquires on return'); + + await page.close(); + + // Reduced motion: designed scenes render one frozen frame. + const reducedPage = await browser.newPage({ + viewport: VIEWPORT, + deviceScaleFactor: 1, + reducedMotion: 'reduce', + }); + await reducedPage.goto(new URL(spec.path ?? '', BASE_URL).href, { + waitUntil: 'networkidle', + timeout: 180000, + }); + await settleForSpec(reducedPage, spec); + const reducedCanvas = await waitForLoadedCanvas( + reducedPage, + spec.slotSelector, + ); + if (reducedCanvas !== null) { + const frozenA = await readClip(reducedPage, reducedCanvas); + await reducedPage.waitForTimeout(400); + const frozenB = await readClip(reducedPage, reducedCanvas); + const frozenDiff = diffClips(frozenA, frozenB); + assert( + frozenDiff <= MOTION_DIFF_FLOOR, + `reduced motion frozen (diff ${(frozenDiff * 100).toFixed(2)}%)`, + ); + } else { + // poster mode: no canvas at all is also a pass + const canvasCount = await reducedPage + .locator(`${spec.slotSelector} canvas`) + .count(); + assert(canvasCount === 0, 'reduced motion shows poster (no canvas)'); + } + await reducedPage.close(); +}; + +const keys = + process.argv.slice(2).length > 0 + ? process.argv.slice(2) + : Object.keys(VISUALS); +const browser = await chromium.launch({ channel: 'chrome', headless: true }); + +// Completeness runs only on full sweeps (a single-visual run is a dev loop). +if (process.argv.slice(2).length === 0) { + const specPaths = [ + ...new Set(Object.values(VISUALS).map((spec) => spec.path ?? '')), + ]; + // Pages are checked one at a time so failures stay attributable. + for (const specPath of specPaths) { + // eslint-disable-next-line no-await-in-loop + const completenessPage = await browser.newPage({ viewport: VIEWPORT }); + // eslint-disable-next-line no-await-in-loop + await completenessPage.goto(new URL(specPath, BASE_URL).href, { + waitUntil: 'networkidle', + timeout: 240000, + }); + console.log(`── spec completeness (${specPath || '/'})`); + // eslint-disable-next-line no-await-in-loop + await assertSpecCompleteness( + completenessPage, + new Set(Object.keys(VISUALS)), + ); + // eslint-disable-next-line no-await-in-loop + await completenessPage.close(); + } +} + +for (const key of keys) { + const spec = VISUALS[key]; + if (!spec) { + failures.push(`unknown visual: ${key}`); + continue; + } + // Visuals run one at a time so context counts stay interpretable. + // eslint-disable-next-line no-await-in-loop + await runVisual(browser, key, spec); +} +await browser.close(); + +if (failures.length > 0) { + console.error(`\nvisual-battery: FAILED (${failures.length})`); + process.exit(1); +} +console.log('\nvisual-battery: OK'); diff --git a/packages/twenty-website-redone/scripts/visual-sweep.mjs b/packages/twenty-website-redone/scripts/visual-sweep.mjs new file mode 100644 index 0000000000..b4f99a2861 --- /dev/null +++ b/packages/twenty-website-redone/scripts/visual-sweep.mjs @@ -0,0 +1,300 @@ +import { chromium } from 'playwright'; + +// Whole-page closing batteries: the context cap holds while every slot +// gets evidenced during a full scroll sweep, and the frame loops go idle +// when everything is offscreen. +const BASE_URL = process.env.VISUAL_BATTERY_URL ?? 'http://localhost:3004/'; +// The cap is read LIVE from the budget via the test instrumentation — +// never duplicated here (a raised budget must fail loudly, not silently). +import { PENDING_VISUAL_SLOTS as PENDING_SLOTS } from './pending-visual-slots.mjs'; + +const failures = []; +const assert = (condition, message) => { + console.log(` ${condition ? '✓' : '✗'} ${message}`); + if (!condition) { + failures.push(message); + } +}; + +const browser = await chromium.launch({ channel: 'chrome', headless: true }); +const page = await browser.newPage({ + viewport: { width: 1280, height: 2400 }, + deviceScaleFactor: 1, +}); +// Wave-close gates: every same-origin request resolves, and the page's +// total image weight stays inside the budget. External fallbacks (e.g. +// twenty-icons.com favicons) are by-design misses and out of scope. +const sameOriginNotFound = []; +let totalImageBytes = 0; +page.on('response', (response) => { + const url = response.url(); + const isSameOrigin = url.startsWith(BASE_URL.replace(/\/$/, '')); + if (isSameOrigin && response.status() === 404) { + sameOriginNotFound.push(new URL(url).pathname); + } + if (isSameOrigin && /\.(webp|png|jpe?g|svg|gif|avif)(\?|$)/.test(url)) { + void response + .body() + .then((body) => { + totalImageBytes += body.length; + }) + .catch(() => {}); + } +}); +await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 240000 }); + +const slots = await page.evaluate(() => + [...document.querySelectorAll('[data-illustration]')].map((el) => + el.getAttribute('data-illustration'), + ), +); +console.log(`── sweep over ${slots.length} slots: ${slots.join(', ')}`); + +const evidenced = new Set(); +let maxCount = 0; +const pageHeight = await page.evaluate( + () => document.documentElement.scrollHeight, +); +for (let y = 0; y <= pageHeight; y += 600) { + // eslint-disable-next-line no-await-in-loop + await page.evaluate((scrollY) => window.scrollTo(0, scrollY), y); + // eslint-disable-next-line no-await-in-loop + await page.waitForTimeout(450); + // eslint-disable-next-line no-await-in-loop + const state = await page.evaluate(() => ({ + count: window.__visualRuntimeTest?.getActiveContextCount() ?? -1, + withCanvas: [...document.querySelectorAll('[data-illustration]')] + .filter((el) => el.querySelector('canvas')) + .map((el) => el.getAttribute('data-illustration')), + })); + maxCount = Math.max(maxCount, state.count); + state.withCanvas.forEach((name) => evidenced.add(name)); +} + +const liveCap = await page.evaluate( + () => window.__visualRuntimeTest?.getContextCap() ?? -1, +); +assert(liveCap > 0, `context cap readable from instrumentation (${liveCap})`); +assert( + maxCount <= liveCap, + `context count never exceeds cap (max ${maxCount} ≤ ${liveCap})`, +); +const missing = slots.filter( + (name) => !evidenced.has(name) && !PENDING_SLOTS.has(name), +); +const pending = slots.filter((name) => PENDING_SLOTS.has(name)); +if (pending.length > 0) { + console.log( + ` - pending (AppPreview wave, expected empty): ${pending.join(', ')}`, + ); +} +const pendingButLive = pending.filter((name) => evidenced.has(name)); +assert( + pendingButLive.length === 0, + `PENDING_SLOTS stays honest${pendingButLive.length ? ` (now live, remove: ${pendingButLive.join(', ')})` : ''}`, +); +assert( + missing.length === 0, + `every slot evidenced during sweep${missing.length ? ` (missing: ${missing.join(', ')})` : ''}`, +); + +// Idle: park between sections (helped stage top is canvas-free), wait out +// the dispose grace, then expect the rAF loops quiet. +await page.evaluate(() => window.scrollTo(0, 0)); +await page.waitForTimeout(6000); +const ticksBefore = await page.evaluate( + () => window.__visualRuntimeTest?.getRafTicks() ?? -1, +); +await page.waitForTimeout(2000); +const ticksAfter = await page.evaluate( + () => window.__visualRuntimeTest?.getRafTicks() ?? -1, +); +const heroAreaCanvases = await page.evaluate( + () => document.querySelectorAll('canvas').length, +); +// The top of the page has live visuals only if slots sit in range; allow +// their loops, but if no canvases are mounted the loops must be silent. +if (heroAreaCanvases === 0) { + assert( + ticksAfter - ticksBefore <= 2, + `rAF idle with nothing mounted (${ticksAfter - ticksBefore} ticks/2s)`, + ); +} else { + console.log( + ` - idle check skipped: ${heroAreaCanvases} canvases legitimately in range at page top`, + ); +} + +// Composition checks — both encode bug classes the user caught by eye: +// a 40px off-center hero body (box left-aligned inside a centered grid) +// and muted ink silently bound to black-70 instead of the old site's 60. +await page.evaluate(() => window.scrollTo(0, 0)); +await page.waitForTimeout(600); +const composition = await page.evaluate(() => { + const center = window.innerWidth / 2; + const measure = (element) => { + const rect = element.getBoundingClientRect(); + return Math.round(rect.left + rect.width / 2 - center); + }; + const h1 = document.querySelector('h1'); + const heroBody = h1?.closest('div')?.parentElement?.querySelector('p'); + const stage = document.querySelector('[data-mockup-stage]'); + const styles = getComputedStyle(document.body); + return { + h1Offset: h1 ? measure(h1) : null, + bodyOffset: heroBody ? measure(heroBody) : null, + stageOffset: stage ? measure(stage) : null, + inkMuted: styles.getPropertyValue('--ink-muted').trim(), + black60: styles.getPropertyValue('--color-black-60').trim(), + }; +}); +for (const [label, offset] of [ + ['h1', composition.h1Offset], + ['hero body', composition.bodyOffset], + ['mockup stage', composition.stageOffset], +]) { + assert( + offset !== null && Math.abs(offset) <= 1, + `${label} centered (offset ${offset}px)`, + ); +} +assert( + composition.inkMuted !== '' && composition.inkMuted === composition.black60, + `muted ink binds to black-60 (got "${composition.inkMuted}" vs "${composition.black60}")`, +); + +// Universal eyebrow-to-heading measure: 24px everywhere the intro +// grammar runs (testimonials' 56px carousel header is the authored +// old-parity exception, asserted so drift is still caught). +const readEyebrowGaps = (target) => + target.evaluate(() => + [...document.querySelectorAll('p')] + .filter( + (el) => + el.querySelector('span[aria-hidden]') && + el.textContent.trim().length < 40, + ) + .map((row) => { + const block = row.parentElement; + const heading = + block.querySelector('h1, h2, h3') ?? + block.parentElement.querySelector('h1, h2, h3'); + const rect = row.getBoundingClientRect(); + const parentRect = row.parentElement.getBoundingClientRect(); + const centered = + getComputedStyle(row.parentElement).textAlign === 'center'; + return { + label: row.textContent.trim(), + gap: heading + ? Math.round(heading.getBoundingClientRect().top - rect.bottom) + : null, + // Centered intros must center the row as one unit (a grid + // ancestor blockifying the inline-flex eyebrow broke this once). + misaligned: + centered && + Math.abs( + rect.left - parentRect.left - (parentRect.right - rect.right), + ) > 2, + }; + }), + ); +const EYEBROW_GAP_PX = 24; +const TESTIMONIALS_EYEBROW = 'They are the real sales'; +const TESTIMONIALS_GAP_PX = 56; + +const readSectionRhythms = (target) => + target.evaluate(() => + [...document.querySelectorAll('section')].map((el) => { + const previous = el.previousElementSibling; + return { + rhythm: el.getAttribute('data-rhythm'), + paddingTop: getComputedStyle(el).paddingTop, + paddingBottom: getComputedStyle(el).paddingBottom, + // Same-scheme neighbors share one surface: the second section + // drops its top padding (flush neighbors contribute no spacing, + // so they never trigger the collapse). + collapsesTop: + previous?.tagName === 'SECTION' && + previous.getAttribute('data-scheme') === + el.getAttribute('data-scheme') && + previous.getAttribute('data-rhythm') !== 'flush', + }; + }), + ); +const homeSections = await readSectionRhythms(page); +const homeEyebrows = await readEyebrowGaps(page); + +// The product page joins the 404 net (its slots/composition have their +// own battery; the sweep guards asset integrity site-wide). +await page.goto(`${BASE_URL.replace(/\/$/, '')}/product`, { + waitUntil: 'networkidle', + timeout: 240000, +}); +const productPageHeight = await page.evaluate( + () => document.documentElement.scrollHeight, +); +for (let y = 0; y <= productPageHeight; y += 600) { + // eslint-disable-next-line no-await-in-loop + await page.evaluate((scrollY) => window.scrollTo(0, scrollY), y); + // eslint-disable-next-line no-await-in-loop + await page.waitForTimeout(350); +} + +// Universal section rhythm: every
is a SectionShell with a +// rhythm class, and its block padding is exactly the token value +// (md tier at this viewport — src/tokens/rhythm.ts, spacing unit 4px). +const RHYTHM_MD_PADDING = { + hero: '48px', + section: '64px', + flush: '0px', + spacious: '120px', +}; +const offRhythmSections = [ + ...homeSections, + ...(await readSectionRhythms(page)), +]; +const allEyebrows = [...homeEyebrows, ...(await readEyebrowGaps(page))]; +const eyebrowViolations = allEyebrows.filter( + (eyebrow) => + eyebrow.misaligned || + (eyebrow.label.startsWith(TESTIMONIALS_EYEBROW) + ? eyebrow.gap !== TESTIMONIALS_GAP_PX + : eyebrow.gap !== EYEBROW_GAP_PX), +); +assert( + allEyebrows.length > 0 && eyebrowViolations.length === 0, + `every eyebrow sits ${EYEBROW_GAP_PX}px above its heading, centered intros centered (${allEyebrows.length} eyebrows${eyebrowViolations.length > 0 ? `; violations: ${JSON.stringify(eyebrowViolations)}` : ''})`, +); + +const rhythmViolations = offRhythmSections.filter( + (section) => + !(section.rhythm in RHYTHM_MD_PADDING) || + section.paddingTop !== + (section.collapsesTop ? '0px' : RHYTHM_MD_PADDING[section.rhythm]) || + section.paddingBottom !== RHYTHM_MD_PADDING[section.rhythm], +); +assert( + offRhythmSections.length > 0 && rhythmViolations.length === 0, + `every section carries token rhythm padding (same-scheme seams collapsed) (${offRhythmSections.length} sections${rhythmViolations.length > 0 ? `; violations: ${JSON.stringify(rhythmViolations)}` : ''})`, +); + +assert( + sameOriginNotFound.length === 0, + `no same-origin 404s during the sweep${sameOriginNotFound.length > 0 ? ` (${sameOriginNotFound.join(', ')})` : ''}`, +); +// Budget recalibrated at the product wave close: both pages fully +// landed measure ~1.79MB worst-case (lazy-load timing varies runs by +// ~350KB); headroom covers that variance, not new art. +const IMAGE_BYTES_BUDGET = 2_100_000; +assert( + totalImageBytes > 0 && totalImageBytes <= IMAGE_BYTES_BUDGET, + `total same-origin image bytes within budget (${Math.round(totalImageBytes / 1024)}KB ≤ ${Math.round(IMAGE_BYTES_BUDGET / 1024)}KB)`, +); + +await browser.close(); + +if (failures.length > 0) { + console.error(`visual-sweep: FAILED (${failures.length})`); + process.exit(1); +} +console.log('visual-sweep: OK'); diff --git a/packages/twenty-website-redone/src/app-preview/AppPreview.tsx b/packages/twenty-website-redone/src/app-preview/AppPreview.tsx new file mode 100644 index 0000000000..98e025c5d0 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/AppPreview.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useEffect } from 'react'; + +import { scheduleIdleTask } from '@/platform/motion'; + +import { APP_PREVIEW_CONFIG } from './data/sidebar-config'; +import { PreviewAppLayout } from './shell/PreviewAppLayout'; +import { useAppPreviewExperience } from './shell/use-app-preview-experience'; +import { AppWindow } from './stage/AppWindow'; +import { ProductFrame } from './stage/ProductFrame'; +import { WindowOrderProvider } from './stage/WindowOrderProvider'; +import { Terminal } from './terminal/Terminal'; + +// The product mockup: navigable sidebar + the object pages, presented +// as a draggable/resizable desktop window (the old hero's identity), with +// the AI Terminal floating beside it. The chat's object-creation beats +// drive the sidebar reveals and page jumps. +export function AppPreview({ + mode = 'windowed', +}: { + mode?: 'static' | 'windowed'; +}) { + useEffect( + () => + scheduleIdleTask(() => { + void import('./pages/dashboard/DashboardPage'); + }), + [], + ); + const experience = useAppPreviewExperience(APP_PREVIEW_CONFIG); + const { + activeItem, + activeItemId, + activePage, + handleChatReset, + handleJumpToConversationEnd, + handleObjectCreated, + highlightedItemId, + revealedObjectIds, + sidebarEntries, + } = experience; + const appShell = ( + + ); + + if (mode === 'static') { + return {appShell}; + } + return ( + + {appShell} + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/app-preview-chrome.ts b/packages/twenty-website-redone/src/app-preview/app-preview-chrome.ts new file mode 100644 index 0000000000..e6e5a01400 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/app-preview-chrome.ts @@ -0,0 +1,12 @@ +import { THEME_COMMON } from 'twenty-ui/theme'; + +// Product layout facts the mockup mirrors that aren't part of twenty-ui's +// theme. The spacing base and nav-item height derive from twenty-ui's spacing +// unit; the drawer width and record-table row height are twenty-front layout +// constants (NavigationDrawerConstraints / RecordTableRowHeight). +export const APP_PREVIEW_CHROME = { + spacingBasePx: THEME_COMMON.spacingMultiplicator, + navigationItemHeightPx: THEME_COMMON.spacingMultiplicator * 7, + navigationDrawerWidthPx: 220, + recordTableRowHeightPx: 32, +}; diff --git a/packages/twenty-website-redone/src/app-preview/data/companies-table-page.ts b/packages/twenty-website-redone/src/app-preview/data/companies-table-page.ts new file mode 100644 index 0000000000..26da1ae7a7 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/companies-table-page.ts @@ -0,0 +1,741 @@ +// The Companies table the mockup opens on — extracted verbatim from the +// old app-preview data (the battery A/B verifies the rendered output). +import { sharedAssetUrls } from './shared-asset-urls'; +import { type TablePageDefinition } from '../types'; + +const PEOPLE_AVATAR_URLS = sharedAssetUrls.peopleAvatars; + +export const COMPANIES_TABLE_PAGE: TablePageDefinition = { + type: 'table', + header: { + title: 'All Companies', + count: 9, + }, + columns: [ + { + id: 'company', + label: 'Companies', + width: 180, + isFirstColumn: true, + }, + { id: 'url', label: 'Url', width: 140 }, + { id: 'createdBy', label: 'Created By', width: 150 }, + { id: 'address', label: 'Address', width: 120 }, + { id: 'accountOwner', label: 'Account Owner', width: 150 }, + { id: 'icp', label: 'ICP', width: 80 }, + { id: 'arr', label: 'ARR', width: 120, align: 'right' }, + { id: 'linkedin', label: 'Linkedin', width: 96 }, + { id: 'industry', label: 'Industry', width: 96 }, + { id: 'mainContact', label: 'Main contact', width: 120 }, + { + id: 'employees', + label: 'Employees', + width: 120, + align: 'right', + }, + { id: 'opportunities', label: 'Opportunities', width: 122 }, + { id: 'added', label: 'Added', width: 120 }, + ], + rows: [ + { + id: 'anthropic', + cells: { + company: { + type: 'entity', + name: 'Anthropic', + domain: 'anthropic.com', + }, + url: { type: 'link', kind: 'url', value: 'anthropic.com' }, + createdBy: { + type: 'person', + name: 'Dario Amodei', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei, + }, + address: { type: 'text', value: '18 Rue De Navarin' }, + accountOwner: { + type: 'person', + name: 'Dario Amodei', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei, + }, + icp: { type: 'boolean', value: true }, + arr: { type: 'currency', value: '$500,000' }, + linkedin: { + type: 'link', + kind: 'social', + value: 'anthropic', + }, + industry: { type: 'select', value: 'AI Research' }, + mainContact: { + type: 'person', + name: 'Dario Amodei', + shortLabel: 'D', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei, + }, + employees: { type: 'number', value: '612' }, + opportunities: { + type: 'relation', + items: [ + { + name: 'Enterprise Expansion', + shortLabel: 'E', + tone: 'blue', + }, + ], + }, + added: { type: 'text', value: 'Jul 1, 2023' }, + }, + }, + { + id: 'linkedin', + cells: { + company: { + type: 'entity', + name: 'Linkedin', + domain: 'linkedin.com', + }, + url: { type: 'link', kind: 'url', value: 'linkedin.com' }, + createdBy: { + type: 'person', + name: 'Reid Hoffman', + tone: 'purple', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.reidHoffman, + }, + address: { type: 'text', value: '1226 Moises Causeway' }, + accountOwner: { + type: 'person', + name: 'Ryan Roslansky', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.ryanRoslansky, + }, + icp: { type: 'boolean', value: false }, + arr: { type: 'currency', value: '$1,000,000' }, + linkedin: { type: 'link', kind: 'social', value: 'linkedin' }, + industry: { + type: 'select', + value: 'Professional Networking', + }, + mainContact: { + type: 'person', + name: 'Ryan Roslansky', + shortLabel: 'R', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.ryanRoslansky, + }, + employees: { type: 'number', value: '19,300' }, + opportunities: { + type: 'relation', + items: [ + { + name: 'Talent Outreach', + shortLabel: 'T', + tone: 'purple', + }, + ], + }, + added: { type: 'text', value: 'Jul 3, 2023' }, + }, + }, + { + id: 'slack', + cells: { + company: { + type: 'entity', + name: 'Slack', + domain: 'slack.com', + }, + url: { type: 'link', kind: 'url', value: 'slack.com' }, + createdBy: { + type: 'person', + name: 'Stewart Butterfield', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.stewartButterfield, + }, + address: { type: 'text', value: '1316 Dameon Mountain' }, + accountOwner: { + type: 'person', + name: 'Stewart Butterfield', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.stewartButterfield, + }, + icp: { type: 'boolean', value: true }, + arr: { type: 'currency', value: '$2,300,000' }, + linkedin: { type: 'link', kind: 'social', value: 'slack' }, + industry: { type: 'select', value: 'Collaboration Software' }, + mainContact: { + type: 'person', + name: 'Lidiane Jones', + shortLabel: 'LJ', + tone: 'pink', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.anonymousIndira, + }, + employees: { type: 'number', value: '4,500' }, + opportunities: { + type: 'relation', + items: [ + { + name: 'Workspace Renewal', + shortLabel: 'W', + tone: 'teal', + }, + ], + }, + added: { type: 'text', value: 'Jul 5, 2023' }, + }, + }, + { + id: 'notion', + cells: { + company: { + type: 'entity', + name: 'Notion', + domain: 'notion.com', + }, + url: { type: 'link', kind: 'url', value: 'notion.com' }, + createdBy: { + type: 'person', + name: 'API - Key name', + tone: 'gray', + kind: 'api', + shortLabel: 'API', + }, + address: { type: 'text', value: '1162 Sammy Creek' }, + accountOwner: { + type: 'person', + name: 'Ivan Zhao', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao, + }, + icp: { type: 'boolean', value: false }, + arr: { type: 'currency', value: '$750,000' }, + linkedin: { type: 'link', kind: 'social', value: 'notion' }, + industry: { type: 'select', value: 'Productivity Software' }, + mainContact: { + type: 'person', + name: 'Ivan Zhao', + shortLabel: 'I', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao, + }, + employees: { type: 'number', value: '620' }, + opportunities: { + type: 'relation', + items: [ + { + name: 'Workspace Consolidation', + shortLabel: 'W', + tone: 'gray', + }, + ], + }, + added: { type: 'text', value: 'Jul 8, 2023' }, + }, + }, + { + id: 'figma', + cells: { + company: { + type: 'entity', + name: 'Figma', + domain: 'figma.com', + }, + url: { type: 'link', kind: 'url', value: 'figma.com' }, + createdBy: { + type: 'person', + name: 'Workflow name', + tone: 'gray', + kind: 'workflow', + shortLabel: 'WF', + }, + address: { type: 'text', value: '110 Oswald Junction' }, + accountOwner: { + type: 'person', + name: 'Dylan Field', + tone: 'purple', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.dylanField, + }, + icp: { type: 'boolean', value: true }, + arr: { type: 'currency', value: '$3,500,000' }, + linkedin: { type: 'link', kind: 'social', value: 'figma' }, + industry: { type: 'select', value: 'Design Tools' }, + mainContact: { + type: 'person', + name: 'Dylan Field', + shortLabel: 'D', + tone: 'purple', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.dylanField, + }, + employees: { type: 'number', value: '1,300' }, + opportunities: { + type: 'relation', + items: [ + { + name: 'AI Prototyping', + shortLabel: 'AI', + tone: 'purple', + }, + { name: 'Design Ops', shortLabel: 'D', tone: 'teal' }, + ], + }, + added: { type: 'text', value: 'Jul 12, 2023' }, + }, + }, + { + id: 'github', + cells: { + company: { + type: 'entity', + name: 'Github', + domain: 'github.com', + }, + url: { type: 'link', kind: 'url', value: 'github.com' }, + createdBy: { + type: 'person', + name: 'Chris Wanstrath', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.chrisWanstrath, + }, + address: { type: 'text', value: '3891 Ranchview Drive' }, + accountOwner: { + type: 'person', + name: 'Thomas Dohmke', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.thomasDohmke, + }, + icp: { type: 'boolean', value: true }, + arr: { type: 'currency', value: '$900,000' }, + linkedin: { type: 'link', kind: 'social', value: 'github' }, + industry: { type: 'select', value: 'Developer Platform' }, + mainContact: { + type: 'person', + name: 'Thomas Dohmke', + shortLabel: 'T', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.thomasDohmke, + }, + employees: { type: 'number', value: '3,800' }, + opportunities: { + type: 'relation', + items: [ + { + name: 'Copilot Rollout', + shortLabel: 'C', + tone: 'blue', + }, + ], + }, + added: { type: 'text', value: 'Jul 14, 2023' }, + }, + }, + { + id: 'airbnb', + cells: { + company: { + type: 'entity', + name: 'Airbnb', + domain: 'airbnb.com', + }, + url: { type: 'link', kind: 'url', value: 'airbnb.com' }, + createdBy: { + type: 'person', + name: 'Joe Gebbia', + tone: 'pink', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.joeGebbia, + }, + address: { type: 'text', value: '4517 Washington Avenue' }, + accountOwner: { + type: 'person', + name: 'Brian Chesky', + tone: 'pink', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.brianChesky, + }, + icp: { type: 'boolean', value: true }, + arr: { type: 'currency', value: '$4,200,000' }, + linkedin: { type: 'link', kind: 'social', value: 'airbnb' }, + industry: { type: 'select', value: 'Travel' }, + mainContact: { + type: 'person', + name: 'Brian Chesky', + shortLabel: 'B', + tone: 'pink', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.brianChesky, + }, + employees: { type: 'number', value: '6,900' }, + opportunities: { + type: 'relation', + items: [{ name: 'Host Ops', shortLabel: 'H', tone: 'pink' }], + }, + added: { type: 'text', value: 'Jul 15, 2023' }, + }, + }, + { + id: 'stripe', + cells: { + company: { + type: 'entity', + name: 'Stripe', + domain: 'stripe.com', + }, + url: { type: 'link', kind: 'url', value: 'stripe.com' }, + createdBy: { + type: 'person', + name: 'Patrick Collison', + tone: 'blue', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison, + }, + address: { type: 'text', value: '2118 Thornridge Circle' }, + accountOwner: { + type: 'person', + name: 'Patrick Collison', + tone: 'blue', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison, + }, + icp: { type: 'boolean', value: true }, + arr: { type: 'currency', value: '$1,800,000' }, + linkedin: { type: 'link', kind: 'social', value: 'stripe' }, + industry: { type: 'select', value: 'Payments' }, + mainContact: { + type: 'person', + name: 'Patrick Collison', + shortLabel: 'P', + tone: 'blue', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison, + }, + employees: { type: 'number', value: '7,400' }, + opportunities: { + type: 'relation', + items: [ + { + name: 'Billing Expansion', + shortLabel: 'B', + tone: 'purple', + }, + ], + }, + added: { type: 'text', value: 'Jul 17, 2023' }, + }, + }, + { + id: 'sequoia', + cells: { + company: { + type: 'entity', + name: 'Sequoia', + domain: 'sequoia.com', + }, + url: { type: 'link', kind: 'url', value: 'sequoia.com' }, + createdBy: { + type: 'person', + name: 'Roelof Botha', + tone: 'green', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.roelofBotha, + }, + address: { type: 'text', value: '1316 Dameon Mountain' }, + accountOwner: { + type: 'person', + name: 'Roelof Botha', + tone: 'green', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.roelofBotha, + }, + icp: { type: 'boolean', value: false }, + arr: { type: 'currency', value: '$6,000,000' }, + linkedin: { type: 'link', kind: 'social', value: 'sequoia' }, + industry: { type: 'select', value: 'Venture Capital' }, + mainContact: { + type: 'person', + name: 'Roelof Botha', + shortLabel: 'R', + tone: 'green', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.roelofBotha, + }, + employees: { type: 'number', value: '1,100' }, + opportunities: { + type: 'relation', + items: [{ name: 'Fund Ops', shortLabel: 'F', tone: 'green' }], + }, + added: { type: 'text', value: 'Jul 20, 2023' }, + }, + }, + { + id: 'segment', + cells: { + company: { + type: 'entity', + name: 'Segment', + domain: 'segment.com', + }, + url: { type: 'link', kind: 'url', value: 'segment.com' }, + createdBy: { + type: 'person', + name: 'Peter Reinhardt', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.peterReinhardt, + }, + address: { type: 'text', value: '8502 Preston Rd. East' }, + accountOwner: { + type: 'person', + name: 'Peter Reinhardt', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.peterReinhardt, + }, + icp: { type: 'boolean', value: true }, + arr: { type: 'currency', value: '$2,750,000' }, + linkedin: { type: 'link', kind: 'social', value: 'segment' }, + industry: { type: 'select', value: 'Customer Data' }, + mainContact: { + type: 'person', + name: 'Peter Reinhardt', + shortLabel: 'P', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.peterReinhardt, + }, + employees: { type: 'number', value: '1,550' }, + opportunities: { + type: 'relation', + items: [ + { + name: 'Warehouse Rollout', + shortLabel: 'W', + tone: 'teal', + }, + ], + }, + added: { type: 'text', value: 'Jul 21, 2023' }, + }, + }, + { + id: 'mailchimp', + cells: { + company: { + type: 'entity', + name: 'Mailchimp', + domain: 'mailchimp.com', + }, + url: { type: 'link', kind: 'url', value: 'mailchimp.com' }, + createdBy: { + type: 'person', + name: 'Ben Chestnut', + tone: 'amber', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.benChestnut, + }, + address: { type: 'text', value: '3517 W. Gray St.' }, + accountOwner: { + type: 'person', + name: 'Ben Chestnut', + tone: 'amber', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.benChestnut, + }, + icp: { type: 'boolean', value: false }, + arr: { type: 'currency', value: '$1,250,000' }, + linkedin: { + type: 'link', + kind: 'social', + value: 'mailchimp', + }, + industry: { type: 'select', value: 'Marketing Automation' }, + mainContact: { + type: 'person', + name: 'Rania Succar', + shortLabel: 'R', + tone: 'amber', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.anonymousLaura, + }, + employees: { type: 'number', value: '1,900' }, + opportunities: { + type: 'relation', + items: [ + { + name: 'Lifecycle Campaigns', + shortLabel: 'L', + tone: 'amber', + }, + ], + }, + added: { type: 'text', value: 'Jul 23, 2023' }, + }, + }, + { + id: 'accel', + cells: { + company: { + type: 'entity', + name: 'Accel', + domain: 'accel.com', + }, + url: { type: 'link', kind: 'url', value: 'accel.com' }, + createdBy: { + type: 'person', + name: 'Ray Damm', + tone: 'purple', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.rayDamm, + }, + address: { type: 'text', value: '4140 Parker Rd.' }, + accountOwner: { + type: 'person', + name: 'Ping Li', + tone: 'purple', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.pingLi, + }, + icp: { type: 'boolean', value: true }, + arr: { type: 'currency', value: '$5,800,000' }, + linkedin: { type: 'link', kind: 'social', value: 'accel' }, + industry: { type: 'select', value: 'Venture Capital' }, + mainContact: { + type: 'person', + name: 'Ping Li', + shortLabel: 'P', + tone: 'purple', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.pingLi, + }, + employees: { type: 'number', value: '540' }, + opportunities: { + type: 'relation', + items: [ + { + name: 'Portfolio Sync', + shortLabel: 'P', + tone: 'purple', + }, + ], + }, + added: { type: 'text', value: 'Jul 24, 2023' }, + }, + }, + { + id: 'founders-fund', + cells: { + company: { + type: 'entity', + name: 'Founders Fund', + domain: 'foundersfund.com', + }, + url: { type: 'link', kind: 'url', value: 'foundersfund.com' }, + createdBy: { + type: 'person', + name: 'System', + tone: 'gray', + kind: 'system', + shortLabel: 'SYS', + }, + address: { type: 'text', value: '2715 Ash Dr. San Jose' }, + accountOwner: { + type: 'person', + name: 'Peter Thiel', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.peterThiel, + }, + icp: { type: 'boolean', value: true }, + arr: { type: 'currency', value: '$2,100,000' }, + linkedin: { + type: 'link', + kind: 'social', + value: 'foundersfund', + }, + industry: { type: 'select', value: 'Private Equity' }, + mainContact: { + type: 'person', + name: 'Peter Thiel', + shortLabel: 'P', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.peterThiel, + }, + employees: { type: 'number', value: '734' }, + opportunities: { + type: 'relation', + items: [{ name: 'Fundraising', shortLabel: 'F', tone: 'gray' }], + }, + added: { type: 'text', value: 'Jul 25, 2023' }, + }, + }, + { + id: 'google', + cells: { + company: { + type: 'entity', + name: 'Google', + domain: 'google.com', + }, + url: { type: 'link', kind: 'url', value: 'google.com' }, + createdBy: { + type: 'person', + name: 'Sundar Pichai', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.sundarPichai, + }, + address: { type: 'text', value: '4140 Parker Rd.' }, + accountOwner: { + type: 'person', + name: 'Sundar Pichai', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.sundarPichai, + }, + icp: { type: 'boolean', value: false }, + arr: { type: 'currency', value: '$7,500,000' }, + linkedin: { type: 'link', kind: 'social', value: 'google' }, + industry: { type: 'select', value: 'Computer Software' }, + mainContact: { + type: 'person', + name: 'Sundar Pichai', + shortLabel: 'S', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.sundarPichai, + }, + employees: { type: 'number', value: '734' }, + opportunities: { + type: 'relation', + items: [ + { + name: 'Google AI and Data Solutions', + shortLabel: 'G', + tone: 'teal', + }, + { name: 'Relation 2', shortLabel: 'L', tone: 'teal' }, + { name: 'Relation 3', shortLabel: 'L', tone: 'teal' }, + ], + }, + added: { type: 'text', value: 'Jul 1, 2023 2:25 pm' }, + }, + }, + ], +}; diff --git a/packages/twenty-website-redone/src/app-preview/data/dashboards-table-page.ts b/packages/twenty-website-redone/src/app-preview/data/dashboards-table-page.ts new file mode 100644 index 0000000000..fb88f361a1 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/dashboards-table-page.ts @@ -0,0 +1,58 @@ +// Extracted verbatim from the old data (the dashboards table). +import { sharedAssetUrls } from './shared-asset-urls'; +import { type TablePageDefinition } from '../types'; + +const PEOPLE_AVATAR_URLS = sharedAssetUrls.peopleAvatars; + +export const DASHBOARDS_TABLE_PAGE: TablePageDefinition = { + type: 'table', + header: { + title: 'All Dashboards', + count: 2, + }, + columns: [ + { id: 'name', label: 'Name', width: 240, isFirstColumn: true }, + { id: 'createdBy', label: 'Created By', width: 160 }, + { id: 'added', label: 'Last Edited', width: 160 }, + ], + rows: [ + { + id: 'sales-dashboard', + cells: { + name: { + type: 'text', + value: 'Sales Dashboard', + shortLabel: 'S', + tone: 'amber', + }, + createdBy: { + type: 'person', + name: 'Dario Amodei', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei, + }, + added: { type: 'text', value: 'Oct 24, 2023' }, + }, + }, + { + id: 'pipeline-health', + cells: { + name: { + type: 'text', + value: 'Pipeline Health', + shortLabel: 'P', + tone: 'blue', + }, + createdBy: { + type: 'person', + name: 'Patrick Collison', + tone: 'blue', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison, + }, + added: { type: 'text', value: 'Oct 19, 2023' }, + }, + }, + ], +}; diff --git a/packages/twenty-website-redone/src/app-preview/data/normalize-page.ts b/packages/twenty-website-redone/src/app-preview/data/normalize-page.ts new file mode 100644 index 0000000000..462d161391 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/normalize-page.ts @@ -0,0 +1,67 @@ +import { + type KanbanPageDefinition, + type PageDefinition, + type SidebarPageItemDef, + type TablePageDefinition, +} from '../types'; + +// Pages are authored sparse; the navigation layer fills the product's +// defaults — including the list icon, count, and the 1700px table canvas +// whose overshoot renders the "+ add field" filler column. +type PageDefaults = { + defaultActions: string[]; + defaultTableWidth: number; +}; + +function normalizeTablePage( + page: TablePageDefinition, + defaults: PageDefaults, +): TablePageDefinition { + return { + ...page, + header: { + ...page.header, + actions: page.header.actions ?? defaults.defaultActions, + count: page.header.count ?? page.rows.length, + showListIcon: page.header.showListIcon ?? true, + }, + width: page.width ?? defaults.defaultTableWidth, + }; +} + +function normalizeKanbanPage( + page: KanbanPageDefinition, + defaults: PageDefaults, +): KanbanPageDefinition { + return { + ...page, + header: { + ...page.header, + actions: page.header.actions ?? defaults.defaultActions, + count: + page.header.count ?? + page.lanes.reduce((sum, lane) => sum + lane.cards.length, 0), + showListIcon: page.header.showListIcon ?? true, + }, + }; +} + +export function normalizePage( + item: SidebarPageItemDef, + defaults: PageDefaults, +): PageDefinition { + const page = item.page; + if (page.type === 'table') { + return normalizeTablePage(page, defaults); + } + if (page.type === 'kanban') { + return normalizeKanbanPage(page, defaults); + } + return { + ...page, + header: { + ...page.header, + showListIcon: page.header.showListIcon ?? false, + }, + }; +} diff --git a/packages/twenty-website-redone/src/app-preview/data/notes-table-page.ts b/packages/twenty-website-redone/src/app-preview/data/notes-table-page.ts new file mode 100644 index 0000000000..a7d3af25d4 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/notes-table-page.ts @@ -0,0 +1,69 @@ +// Extracted verbatim from the old data (the notes table). +import { sharedAssetUrls } from './shared-asset-urls'; +import { type TablePageDefinition } from '../types'; + +const PEOPLE_AVATAR_URLS = sharedAssetUrls.peopleAvatars; + +export const NOTES_TABLE_PAGE: TablePageDefinition = { + type: 'table', + header: { + title: 'All Notes', + count: 2, + }, + columns: [ + { id: 'title', label: 'Title', width: 240, isFirstColumn: true }, + { id: 'createdBy', label: 'Created By', width: 160 }, + { id: 'relatedTo', label: 'Related To', width: 160 }, + { id: 'added', label: 'Added', width: 180 }, + ], + rows: [ + { + id: 'discovery-call', + cells: { + title: { + type: 'text', + value: 'Discovery call notes', + shortLabel: 'D', + tone: 'green', + }, + createdBy: { + type: 'person', + name: 'Ivan Zhao', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao, + }, + relatedTo: { + type: 'entity', + name: 'Notion', + domain: 'notion.com', + }, + added: { type: 'text', value: 'Sep 2, 2023' }, + }, + }, + { + id: 'design-system-meeting', + cells: { + title: { + type: 'text', + value: 'Design system meeting', + shortLabel: 'D', + tone: 'green', + }, + createdBy: { + type: 'person', + name: 'Dylan Field', + tone: 'purple', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.dylanField, + }, + relatedTo: { + type: 'entity', + name: 'Figma', + domain: 'figma.com', + }, + added: { type: 'text', value: 'Oct 18, 2023' }, + }, + }, + ], +}; diff --git a/packages/twenty-website-redone/src/app-preview/data/object-pinned-actions.ts b/packages/twenty-website-redone/src/app-preview/data/object-pinned-actions.ts new file mode 100644 index 0000000000..76860a41f0 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/object-pinned-actions.ts @@ -0,0 +1,26 @@ +import { type NavbarAction } from '../types'; + +// Each created object pins 2-3 quick commands next to the navbar's New +// button, keyed by the object's sidebar id. +export const OBJECT_PINNED_ACTIONS: Record = { + rockets: [ + { icon: 'repeat', label: 'Fly again' }, + { icon: 'calendarPlus', label: 'Schedule launch' }, + { icon: 'playerPause', label: 'Retire' }, + ], + launches: [ + { icon: 'calendarClock', label: 'Reschedule' }, + { icon: 'box', label: 'Add payload' }, + { icon: 'calendarEvent', label: 'Upcoming' }, + ], + payloads: [ + { icon: 'calendarPlus', label: 'Book slot' }, + { icon: 'flag', label: 'Set status' }, + ], + companies: [{ icon: 'flag', label: 'Set status' }], + 'launch-sites': [ + { icon: 'flag', label: 'Set status' }, + { icon: 'calendarPlus', label: 'Book window' }, + { icon: 'rocket', label: 'Launches' }, + ], +}; diff --git a/packages/twenty-website-redone/src/app-preview/data/opportunity-kanban-page.ts b/packages/twenty-website-redone/src/app-preview/data/opportunity-kanban-page.ts new file mode 100644 index 0000000000..83e43b039e --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/opportunity-kanban-page.ts @@ -0,0 +1,224 @@ +// The Opportunities board — extracted verbatim from the old data. +import { sharedAssetUrls } from './shared-asset-urls'; +import { type KanbanPageDefinition } from '../types'; + +const PEOPLE_AVATAR_URLS = sharedAssetUrls.peopleAvatars; + +export const OPPORTUNITY_KANBAN_PAGE: KanbanPageDefinition = { + type: 'kanban', + header: { + title: 'Best leads', + }, + lanes: [ + { + id: 'new', + label: 'New', + tone: 'pink', + cards: [ + { + id: 'anthropic-enterprise-expansion', + title: 'Enterprise Expansion', + amount: '$500,000', + company: { + type: 'entity', + name: 'Anthropic', + domain: 'anthropic.com', + }, + accountOwner: { + type: 'person', + name: 'Eddy Cue', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.eddyCue, + }, + rating: 2, + date: 'Jul 1, 2023', + mainContact: { + type: 'person', + name: 'Dario Amodei', + shortLabel: 'D', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei, + }, + recordId: 'OPP-1', + }, + { + id: 'figma-ai-prototyping', + title: 'AI Prototyping', + amount: '$3,500,000', + company: { type: 'entity', name: 'Figma', domain: 'figma.com' }, + accountOwner: { + type: 'person', + name: 'Jeff Williams', + tone: 'purple', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.jeffWilliams, + }, + rating: 2, + date: 'Jul 12, 2023', + mainContact: { + type: 'person', + name: 'Dylan Field', + shortLabel: 'D', + tone: 'purple', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.dylanField, + }, + recordId: 'OPP-2', + }, + ], + }, + { + id: 'screening', + label: 'Screening', + tone: 'purple', + cards: [ + { + id: 'notion-workspace-consolidation', + title: 'Workspace Consolidation', + amount: '$750,000', + company: { type: 'entity', name: 'Notion', domain: 'notion.com' }, + accountOwner: { + type: 'person', + name: 'Sundar Pichai', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.sundarPichai, + }, + rating: 4, + date: 'Jul 8, 2023', + mainContact: { + type: 'person', + name: 'Ivan Zhao', + shortLabel: 'I', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao, + }, + recordId: 'OPP-3', + }, + ], + }, + { + id: 'meeting', + label: 'Meeting', + tone: 'blue', + cards: [ + { + id: 'github-copilot-rollout', + title: 'Copilot Rollout', + amount: '$900,000', + company: { type: 'entity', name: 'Github', domain: 'github.com' }, + accountOwner: { + type: 'person', + name: 'Chris Wanstrath', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.chrisWanstrath, + }, + rating: 3, + date: 'Jul 14, 2023', + mainContact: { + type: 'person', + name: 'Thomas Dohmke', + shortLabel: 'T', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.thomasDohmke, + }, + recordId: 'OPP-4', + }, + { + id: 'stripe-billing-expansion', + title: 'Billing Expansion', + amount: '$1,800,000', + company: { type: 'entity', name: 'Stripe', domain: 'stripe.com' }, + accountOwner: { + type: 'person', + name: 'Katherine Adams', + tone: 'blue', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.katherineAdams, + }, + rating: 5, + date: 'Jul 17, 2023', + mainContact: { + type: 'person', + name: 'Patrick Collison', + shortLabel: 'P', + tone: 'blue', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison, + }, + recordId: 'OPP-5', + }, + { + id: 'airbnb-host-ops', + title: 'Host Ops', + amount: '$4,200,000', + company: { type: 'entity', name: 'Airbnb', domain: 'airbnb.com' }, + accountOwner: { + type: 'person', + name: 'Joe Gebbia', + tone: 'pink', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.joeGebbia, + }, + rating: 3, + date: 'Jul 15, 2023', + mainContact: { + type: 'person', + name: 'Brian Chesky', + shortLabel: 'B', + tone: 'pink', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.brianChesky, + }, + recordId: 'OPP-6', + }, + ], + }, + { + id: 'proposal', + label: 'Proposal', + tone: 'gray', + cards: [], + }, + { + id: 'customer', + label: 'Customer', + tone: 'green', + cards: [ + { + id: 'mailchimp-lifecycle-campaigns', + title: 'Lifecycle Campaigns', + amount: '$1,250,000', + company: { + type: 'entity', + name: 'Mailchimp', + domain: 'mailchimp.com', + }, + accountOwner: { + type: 'person', + name: 'Ben Chestnut', + tone: 'amber', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.benChestnut, + }, + rating: 4, + date: 'Jul 23, 2023', + mainContact: { + type: 'person', + name: 'Rania Succar', + shortLabel: 'R', + tone: 'amber', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.anonymousLaura, + }, + recordId: 'OPP-7', + }, + ], + }, + ], +}; diff --git a/packages/twenty-website-redone/src/app-preview/data/people-table-page.ts b/packages/twenty-website-redone/src/app-preview/data/people-table-page.ts new file mode 100644 index 0000000000..1e688f9562 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/people-table-page.ts @@ -0,0 +1,200 @@ +// Extracted verbatim from the old data (the people table). +import { sharedAssetUrls } from './shared-asset-urls'; +import { type TablePageDefinition } from '../types'; + +const PEOPLE_AVATAR_URLS = sharedAssetUrls.peopleAvatars; + +export const PEOPLE_TABLE_PAGE: TablePageDefinition = { + type: 'table', + header: { + title: 'All People', + count: 5, + }, + columns: [ + { id: 'name', label: 'Name', width: 180, isFirstColumn: true }, + { id: 'company', label: 'Company', width: 160 }, + { id: 'email', label: 'Email', width: 200 }, + { id: 'phone', label: 'Phone', width: 160 }, + { id: 'jobTitle', label: 'Job Title', width: 160 }, + { id: 'city', label: 'City', width: 120 }, + { id: 'linkedin', label: 'Linkedin', width: 140 }, + { id: 'added', label: 'Added', width: 160 }, + ], + rows: [ + { + id: 'dario-amodei', + cells: { + name: { + type: 'person', + name: 'Dario Amodei', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei, + }, + company: { + type: 'entity', + name: 'Anthropic', + domain: 'anthropic.com', + }, + email: { + type: 'link', + kind: 'email', + value: 'dario@anthropic.com', + }, + phone: { + type: 'link', + kind: 'phone', + value: '+1 415 555 0101', + }, + jobTitle: { type: 'text', value: 'CEO' }, + city: { type: 'text', value: 'San Francisco' }, + linkedin: { + type: 'link', + kind: 'social', + value: 'dario-amodei', + }, + added: { type: 'text', value: 'Jul 3, 2023' }, + }, + }, + { + id: 'ryan-roslansky', + cells: { + name: { + type: 'person', + name: 'Ryan Roslansky', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.ryanRoslansky, + }, + company: { + type: 'entity', + name: 'Linkedin', + domain: 'linkedin.com', + }, + email: { + type: 'link', + kind: 'email', + value: 'ryan@linkedin.com', + }, + phone: { + type: 'link', + kind: 'phone', + value: '+1 650 555 0134', + }, + jobTitle: { type: 'text', value: 'CEO' }, + city: { type: 'text', value: 'Sunnyvale' }, + linkedin: { + type: 'link', + kind: 'social', + value: 'ryanroslansky', + }, + added: { type: 'text', value: 'Jul 28, 2023' }, + }, + }, + { + id: 'stewart-butterfield', + cells: { + name: { + type: 'person', + name: 'Stewart Butterfield', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.stewartButterfield, + }, + company: { + type: 'entity', + name: 'Slack', + domain: 'slack.com', + }, + email: { + type: 'link', + kind: 'email', + value: 'stewart@slack.com', + }, + phone: { + type: 'link', + kind: 'phone', + value: '+1 415 555 0142', + }, + jobTitle: { type: 'text', value: 'Co-founder' }, + city: { type: 'text', value: 'San Francisco' }, + linkedin: { + type: 'link', + kind: 'social', + value: 'stewart-butterfield', + }, + added: { type: 'text', value: 'Jul 18, 2023' }, + }, + }, + { + id: 'ivan-zhao', + cells: { + name: { + type: 'person', + name: 'Ivan Zhao', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao, + }, + company: { + type: 'entity', + name: 'Notion', + domain: 'notion.com', + }, + email: { + type: 'link', + kind: 'email', + value: 'ivan@notion.com', + }, + phone: { + type: 'link', + kind: 'phone', + value: '+1 628 555 0186', + }, + jobTitle: { type: 'text', value: 'CEO' }, + city: { type: 'text', value: 'San Francisco' }, + linkedin: { + type: 'link', + kind: 'social', + value: 'ivanhzhao', + }, + added: { type: 'text', value: 'Jul 8, 2023' }, + }, + }, + { + id: 'dylan-field', + cells: { + name: { + type: 'person', + name: 'Dylan Field', + tone: 'purple', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.dylanField, + }, + company: { + type: 'entity', + name: 'Figma', + domain: 'figma.com', + }, + email: { + type: 'link', + kind: 'email', + value: 'dylan@figma.com', + }, + phone: { + type: 'link', + kind: 'phone', + value: '+1 415 555 0128', + }, + jobTitle: { type: 'text', value: 'CEO' }, + city: { type: 'text', value: 'San Francisco' }, + linkedin: { + type: 'link', + kind: 'social', + value: 'dylanfield', + }, + added: { type: 'text', value: 'Jul 12, 2023' }, + }, + }, + ], +}; diff --git a/packages/twenty-website-redone/src/app-preview/data/rocket-objects.ts b/packages/twenty-website-redone/src/app-preview/data/rocket-objects.ts new file mode 100644 index 0000000000..c18bce0718 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/rocket-objects.ts @@ -0,0 +1,564 @@ +import { type SidebarItemDef, type TablePageDefinition } from '../types'; + +// The AI scenario's four created objects, extracted verbatim from the old +// rocket-object data. Each sequence entry pairs the sidebar item with its +// fully-furnished table page. +const ROCKET_PAGE: TablePageDefinition = { + type: 'table', + header: { + title: 'All Rockets', + count: 6, + }, + columns: [ + { id: 'name', label: 'Name', width: 200, isFirstColumn: true }, + { id: 'serialNumber', label: 'Serial Number', width: 130 }, + { id: 'manufacturer', label: 'Manufacturer', width: 180 }, + { id: 'status', label: 'Status', width: 130 }, + { id: 'reusable', label: 'Reusable', width: 110 }, + { id: 'launchDate', label: 'Launch Date', width: 140 }, + { id: 'targetOrbit', label: 'Target Orbit', width: 140 }, + { id: 'heightMeters', label: 'Height (m)', width: 120, align: 'right' }, + { id: 'massKg', label: 'Mass (kg)', width: 130, align: 'right' }, + ], + rows: [ + { + id: 'falcon-9', + cells: { + name: { + type: 'text', + value: 'Falcon 9', + shortLabel: 'F', + tone: 'blue', + }, + serialNumber: { type: 'text', value: 'B1062' }, + manufacturer: { type: 'entity', name: 'SpaceX', domain: 'spacex.com' }, + status: { type: 'select', color: 'green', value: 'Active' }, + reusable: { type: 'boolean', value: true }, + launchDate: { type: 'text', value: 'Apr 11, 2024' }, + targetOrbit: { type: 'text', value: 'LEO' }, + heightMeters: { type: 'number', value: '70' }, + massKg: { type: 'number', value: '549,054' }, + }, + }, + { + id: 'starship', + cells: { + name: { + type: 'text', + value: 'Starship', + shortLabel: 'S', + tone: 'amber', + }, + serialNumber: { type: 'text', value: 'S29' }, + manufacturer: { type: 'entity', name: 'SpaceX', domain: 'spacex.com' }, + status: { type: 'select', color: 'orange', value: 'Testing' }, + reusable: { type: 'boolean', value: true }, + launchDate: { type: 'text', value: 'Jun 6, 2024' }, + targetOrbit: { type: 'text', value: 'Mars Transfer' }, + heightMeters: { type: 'number', value: '120' }, + massKg: { type: 'number', value: '5,000,000' }, + }, + }, + { + id: 'new-glenn', + cells: { + name: { + type: 'text', + value: 'New Glenn', + shortLabel: 'N', + tone: 'teal', + }, + serialNumber: { type: 'text', value: 'NG-1' }, + manufacturer: { + type: 'entity', + name: 'Blue Origin', + domain: 'blueorigin.com', + }, + status: { type: 'select', color: 'green', value: 'Active' }, + reusable: { type: 'boolean', value: true }, + launchDate: { type: 'text', value: 'Jan 16, 2025' }, + targetOrbit: { type: 'text', value: 'GTO' }, + heightMeters: { type: 'number', value: '98' }, + massKg: { type: 'number', value: '1,400,000' }, + }, + }, + { + id: 'electron', + cells: { + name: { + type: 'text', + value: 'Electron', + shortLabel: 'E', + tone: 'purple', + }, + serialNumber: { type: 'text', value: 'F52' }, + manufacturer: { + type: 'entity', + name: 'Rocket Lab', + domain: 'rocketlabusa.com', + }, + status: { type: 'select', color: 'green', value: 'Active' }, + reusable: { type: 'boolean', value: false }, + launchDate: { type: 'text', value: 'Sep 20, 2024' }, + targetOrbit: { type: 'text', value: 'SSO' }, + heightMeters: { type: 'number', value: '18' }, + massKg: { type: 'number', value: '13,000' }, + }, + }, + { + id: 'ariane-6', + cells: { + name: { + type: 'text', + value: 'Ariane 6', + shortLabel: 'A', + tone: 'pink', + }, + serialNumber: { type: 'text', value: 'VA262' }, + manufacturer: { + type: 'entity', + name: 'Arianespace', + domain: 'arianespace.com', + }, + status: { type: 'select', color: 'green', value: 'Active' }, + reusable: { type: 'boolean', value: false }, + launchDate: { type: 'text', value: 'Jul 9, 2024' }, + targetOrbit: { type: 'text', value: 'GTO' }, + heightMeters: { type: 'number', value: '63' }, + massKg: { type: 'number', value: '860,000' }, + }, + }, + { + id: 'vulcan-centaur', + cells: { + name: { + type: 'text', + value: 'Vulcan Centaur', + shortLabel: 'V', + tone: 'green', + }, + serialNumber: { type: 'text', value: 'VC-002' }, + manufacturer: { + type: 'entity', + name: 'ULA', + domain: 'ulalaunch.com', + }, + status: { type: 'select', color: 'green', value: 'Active' }, + reusable: { type: 'boolean', value: false }, + launchDate: { type: 'text', value: 'Oct 4, 2024' }, + targetOrbit: { type: 'text', value: 'GEO' }, + heightMeters: { type: 'number', value: '62' }, + massKg: { type: 'number', value: '546,700' }, + }, + }, + ], +}; + +const LAUNCH_PAGE: TablePageDefinition = { + type: 'table', + header: { title: 'Launches', count: 5 }, + columns: [ + { id: 'name', label: 'Name', width: 200, isFirstColumn: true }, + { id: 'missionCode', label: 'Mission Code', width: 140 }, + { id: 'status', label: 'Status', width: 130 }, + { id: 'missionType', label: 'Mission Type', width: 140 }, + { id: 'plannedLaunchAt', label: 'Planned Launch', width: 170 }, + { id: 'rocket', label: 'Rocket', width: 170 }, + { id: 'launchSite', label: 'Launch Site', width: 170 }, + { id: 'actualLaunchAt', label: 'Actual Launch', width: 170 }, + ], + rows: [ + { + id: 'crs-29', + cells: { + name: { type: 'text', value: 'CRS-29', shortLabel: 'C', tone: 'blue' }, + missionCode: { type: 'text', value: 'NASA-CRS-29' }, + status: { type: 'select', color: 'green', value: 'Success' }, + missionType: { type: 'select', color: 'orange', value: 'Cargo' }, + plannedLaunchAt: { type: 'text', value: 'Nov 9, 2023' }, + rocket: { + type: 'relation', + items: [{ name: 'Falcon 9', shortLabel: 'F', tone: 'blue' }], + }, + launchSite: { + type: 'relation', + items: [{ name: 'LC-39A', shortLabel: 'K', tone: 'red' }], + }, + actualLaunchAt: { type: 'text', value: 'Nov 9, 2023' }, + }, + }, + { + id: 'artemis-ii', + cells: { + name: { + type: 'text', + value: 'Artemis II', + shortLabel: 'A', + tone: 'amber', + }, + missionCode: { type: 'text', value: 'NASA-ART-2' }, + status: { type: 'select', color: 'blue', value: 'Scheduled' }, + missionType: { type: 'select', color: 'purple', value: 'Crewed' }, + plannedLaunchAt: { type: 'text', value: 'Sep 26, 2025' }, + rocket: { + type: 'relation', + items: [{ name: 'SLS Block 1', shortLabel: 'S', tone: 'purple' }], + }, + launchSite: { + type: 'relation', + items: [{ name: 'LC-39B', shortLabel: 'K', tone: 'red' }], + }, + actualLaunchAt: { type: 'text', value: 'TBD' }, + }, + }, + { + id: 'ift-5', + cells: { + name: { + type: 'text', + value: 'Starship IFT-5', + shortLabel: 'S', + tone: 'amber', + }, + missionCode: { type: 'text', value: 'SPX-IFT-5' }, + status: { type: 'select', color: 'green', value: 'Success' }, + missionType: { type: 'select', color: 'purple', value: 'Test' }, + plannedLaunchAt: { type: 'text', value: 'Oct 13, 2024' }, + rocket: { + type: 'relation', + items: [{ name: 'Starship', shortLabel: 'S', tone: 'amber' }], + }, + launchSite: { + type: 'relation', + items: [{ name: 'Starbase', shortLabel: 'S', tone: 'orange' }], + }, + actualLaunchAt: { type: 'text', value: 'Oct 13, 2024' }, + }, + }, + { + id: 'euclid-launch', + cells: { + name: { type: 'text', value: 'Euclid', shortLabel: 'E', tone: 'teal' }, + missionCode: { type: 'text', value: 'ESA-EUC-1' }, + status: { type: 'select', color: 'green', value: 'Success' }, + missionType: { type: 'select', color: 'blue', value: 'Commercial' }, + plannedLaunchAt: { type: 'text', value: 'Jul 1, 2023' }, + rocket: { + type: 'relation', + items: [{ name: 'Falcon 9', shortLabel: 'F', tone: 'blue' }], + }, + launchSite: { + type: 'relation', + items: [{ name: 'SLC-40', shortLabel: 'C', tone: 'red' }], + }, + actualLaunchAt: { type: 'text', value: 'Jul 1, 2023' }, + }, + }, + { + id: 'psyche-launch', + cells: { + name: { + type: 'text', + value: 'Psyche', + shortLabel: 'P', + tone: 'purple', + }, + missionCode: { type: 'text', value: 'NASA-PSY-1' }, + status: { type: 'select', color: 'green', value: 'Success' }, + missionType: { type: 'select', color: 'blue', value: 'Commercial' }, + plannedLaunchAt: { type: 'text', value: 'Oct 13, 2023' }, + rocket: { + type: 'relation', + items: [{ name: 'Falcon Heavy', shortLabel: 'F', tone: 'blue' }], + }, + launchSite: { + type: 'relation', + items: [{ name: 'LC-39A', shortLabel: 'K', tone: 'red' }], + }, + actualLaunchAt: { type: 'text', value: 'Oct 13, 2023' }, + }, + }, + ], +}; + +const PAYLOAD_PAGE: TablePageDefinition = { + type: 'table', + header: { title: 'Payloads', count: 5 }, + columns: [ + { id: 'name', label: 'Name', width: 200, isFirstColumn: true }, + { id: 'payloadType', label: 'Payload Type', width: 150 }, + { id: 'status', label: 'Status', width: 130 }, + { id: 'customer', label: 'Customer', width: 170 }, + { id: 'launch', label: 'Launch', width: 170 }, + { id: 'targetOrbit', label: 'Target Orbit', width: 150 }, + { id: 'massKg', label: 'Mass (kg)', width: 130, align: 'right' }, + ], + rows: [ + { + id: 'starlink-batch-29', + cells: { + name: { + type: 'text', + value: 'Starlink v2 #29', + shortLabel: 'S', + tone: 'blue', + }, + payloadType: { type: 'select', color: 'blue', value: 'Satellite' }, + status: { type: 'select', color: 'green', value: 'Deployed' }, + customer: { + type: 'relation', + items: [{ name: 'Starlink', shortLabel: 'S', tone: 'blue' }], + }, + launch: { + type: 'relation', + items: [{ name: 'Starlink-Grp-6-20', shortLabel: 'S', tone: 'blue' }], + }, + targetOrbit: { type: 'text', value: 'LEO 550km' }, + massKg: { type: 'number', value: '18,000' }, + }, + }, + { + id: 'orion-artemis', + cells: { + name: { + type: 'text', + value: 'Orion Capsule', + shortLabel: 'O', + tone: 'amber', + }, + payloadType: { type: 'select', color: 'purple', value: 'Crew Capsule' }, + status: { type: 'select', color: 'blue', value: 'Integrated' }, + customer: { + type: 'relation', + items: [{ name: 'NASA', shortLabel: 'N', tone: 'red' }], + }, + launch: { + type: 'relation', + items: [{ name: 'Artemis II', shortLabel: 'A', tone: 'amber' }], + }, + targetOrbit: { type: 'text', value: 'Lunar Transit' }, + massKg: { type: 'number', value: '22,000' }, + }, + }, + { + id: 'dragon-crs-29', + cells: { + name: { + type: 'text', + value: 'Dragon CRS-29', + shortLabel: 'D', + tone: 'blue', + }, + payloadType: { type: 'select', color: 'orange', value: 'Cargo' }, + status: { type: 'select', color: 'green', value: 'Launched' }, + customer: { + type: 'relation', + items: [{ name: 'NASA', shortLabel: 'N', tone: 'red' }], + }, + launch: { + type: 'relation', + items: [{ name: 'CRS-29', shortLabel: 'C', tone: 'blue' }], + }, + targetOrbit: { type: 'text', value: 'LEO' }, + massKg: { type: 'number', value: '12,500' }, + }, + }, + { + id: 'psyche-probe', + cells: { + name: { + type: 'text', + value: 'Psyche Probe', + shortLabel: 'P', + tone: 'purple', + }, + payloadType: { type: 'select', color: 'turquoise', value: 'Probe' }, + status: { type: 'select', color: 'green', value: 'Deployed' }, + customer: { + type: 'relation', + items: [{ name: 'NASA', shortLabel: 'N', tone: 'red' }], + }, + launch: { + type: 'relation', + items: [{ name: 'Psyche', shortLabel: 'P', tone: 'purple' }], + }, + targetOrbit: { type: 'text', value: 'Asteroid belt' }, + massKg: { type: 'number', value: '2,747' }, + }, + }, + { + id: 'euclid-observatory', + cells: { + name: { + type: 'text', + value: 'Euclid Observatory', + shortLabel: 'E', + tone: 'teal', + }, + payloadType: { type: 'select', color: 'blue', value: 'Satellite' }, + status: { type: 'select', color: 'green', value: 'Deployed' }, + customer: { + type: 'relation', + items: [{ name: 'ESA', shortLabel: 'E', tone: 'teal' }], + }, + launch: { + type: 'relation', + items: [{ name: 'Euclid', shortLabel: 'E', tone: 'teal' }], + }, + targetOrbit: { type: 'text', value: 'Sun-Earth L2' }, + massKg: { type: 'number', value: '2,160' }, + }, + }, + ], +}; + +const LAUNCH_SITE_PAGE: TablePageDefinition = { + type: 'table', + header: { title: 'Launch sites', count: 5 }, + columns: [ + { id: 'name', label: 'Name', width: 220, isFirstColumn: true }, + { id: 'siteCode', label: 'Site Code', width: 140 }, + { id: 'padName', label: 'Pad Name', width: 180 }, + { id: 'country', label: 'Country', width: 140 }, + { id: 'siteStatus', label: 'Site Status', width: 150 }, + ], + rows: [ + { + id: 'ksc-39a', + cells: { + name: { + type: 'text', + value: 'Kennedy LC-39A', + shortLabel: 'K', + tone: 'red', + }, + siteCode: { type: 'text', value: 'KSC-39A' }, + padName: { type: 'text', value: 'Launch Complex 39A' }, + country: { type: 'text', value: 'United States' }, + siteStatus: { type: 'select', color: 'green', value: 'Active' }, + }, + }, + { + id: 'ccsfs-slc-40', + cells: { + name: { + type: 'text', + value: 'Cape Canaveral SLC-40', + shortLabel: 'C', + tone: 'red', + }, + siteCode: { type: 'text', value: 'CCSFS-40' }, + padName: { type: 'text', value: 'Space Launch Complex 40' }, + country: { type: 'text', value: 'United States' }, + siteStatus: { type: 'select', color: 'green', value: 'Active' }, + }, + }, + { + id: 'starbase', + cells: { + name: { + type: 'text', + value: 'Starbase', + shortLabel: 'S', + tone: 'orange', + }, + siteCode: { type: 'text', value: 'SB-01' }, + padName: { type: 'text', value: 'Orbital Launch Pad A' }, + country: { type: 'text', value: 'United States' }, + siteStatus: { type: 'select', color: 'green', value: 'Active' }, + }, + }, + { + id: 'vandenberg-slc-4e', + cells: { + name: { + type: 'text', + value: 'Vandenberg SLC-4E', + shortLabel: 'V', + tone: 'purple', + }, + siteCode: { type: 'text', value: 'VSFB-4E' }, + padName: { type: 'text', value: 'Space Launch Complex 4E' }, + country: { type: 'text', value: 'United States' }, + siteStatus: { type: 'select', color: 'green', value: 'Active' }, + }, + }, + { + id: 'kourou-ela-4', + cells: { + name: { + type: 'text', + value: 'Kourou ELA-4', + shortLabel: 'K', + tone: 'teal', + }, + siteCode: { type: 'text', value: 'CSG-4' }, + padName: { type: 'text', value: 'Ensemble de Lancement 4' }, + country: { type: 'text', value: 'French Guiana' }, + siteStatus: { type: 'select', color: 'green', value: 'Active' }, + }, + }, + ], +}; + +const ROCKET_SIDEBAR_ITEM: SidebarItemDef = { + id: 'rockets', + label: 'Rockets', + icon: { kind: 'tabler', name: 'rocket', tone: 'violet' }, + page: ROCKET_PAGE, +}; + +const LAUNCH_SIDEBAR_ITEM: SidebarItemDef = { + id: 'launches', + label: 'Launches', + icon: { kind: 'tabler', name: 'calendarEvent', tone: 'violet' }, + page: LAUNCH_PAGE, +}; + +const PAYLOAD_SIDEBAR_ITEM: SidebarItemDef = { + id: 'payloads', + label: 'Payloads', + icon: { kind: 'tabler', name: 'planet', tone: 'violet' }, + page: PAYLOAD_PAGE, +}; + +const LAUNCH_SITE_SIDEBAR_ITEM: SidebarItemDef = { + id: 'launch-sites', + label: 'Launch sites', + icon: { kind: 'tabler', name: 'mapPin', tone: 'violet' }, + page: LAUNCH_SITE_PAGE, +}; + +type CrmScenarioEntry = { + id: string; + label: string; + sidebarItem: SidebarItemDef; +}; + +const CRM_OBJECT_SEQUENCE: ReadonlyArray = [ + { + id: ROCKET_SIDEBAR_ITEM.id, + label: ROCKET_SIDEBAR_ITEM.label, + sidebarItem: ROCKET_SIDEBAR_ITEM, + }, + { + id: LAUNCH_SIDEBAR_ITEM.id, + label: LAUNCH_SIDEBAR_ITEM.label, + sidebarItem: LAUNCH_SIDEBAR_ITEM, + }, + { + id: PAYLOAD_SIDEBAR_ITEM.id, + label: PAYLOAD_SIDEBAR_ITEM.label, + sidebarItem: PAYLOAD_SIDEBAR_ITEM, + }, + { + id: LAUNCH_SITE_SIDEBAR_ITEM.id, + label: LAUNCH_SITE_SIDEBAR_ITEM.label, + sidebarItem: LAUNCH_SITE_SIDEBAR_ITEM, + }, +]; + +export const CRM_SCENARIO = { + companiesItemId: 'companies', + sequence: CRM_OBJECT_SEQUENCE, +}; diff --git a/packages/twenty-website-redone/src/app-preview/data/sales-dashboard-page.ts b/packages/twenty-website-redone/src/app-preview/data/sales-dashboard-page.ts new file mode 100644 index 0000000000..b56de1f940 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/sales-dashboard-page.ts @@ -0,0 +1,72 @@ +// The Sales Dashboard — extracted verbatim from the old data. +import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones'; +import { type DashboardData, type DashboardPageDefinition } from '../types'; + +const SALES_DASHBOARD_DATA: DashboardData = { + kpis: [ + { + id: 'pipeline', + title: 'Pipeline', + value: '$12.9M', + trend: { direction: 'up', value: '+8%' }, + }, + { + id: 'won-this-quarter', + title: 'Won this quarter', + value: '$2.4M', + trend: { direction: 'up', value: '+12%' }, + }, + { + id: 'win-rate', + title: 'Win rate', + value: '38%', + trend: { direction: 'down', value: '-3%' }, + }, + ], + lineChart: { + title: 'ARR over time', + labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'], + values: [3.1, 3.8, 3.5, 4.6, 5.4, 6.1, 7.2], + }, + barChart: { + title: 'Deals by stage', + bars: [ + { label: 'New', value: 12 }, + { label: 'Screening', value: 9 }, + { label: 'Meeting', value: 7 }, + { label: 'Proposal', value: 5 }, + { label: 'Customer', value: 4 }, + ], + }, + donutChart: { + title: 'By industry', + centerValue: '24', + centerLabel: 'deals', + slices: [ + { label: 'AI', value: 8, color: APP_PREVIEW_TONES.dashboardChart.accent }, + { + label: 'Fintech', + value: 6, + color: APP_PREVIEW_TONES.dashboardChart.slicePurple, + }, + { + label: 'SaaS', + value: 5, + color: APP_PREVIEW_TONES.dashboardChart.trendUp, + }, + { + label: 'Other', + value: 5, + color: APP_PREVIEW_TONES.dashboardChart.sliceOrange, + }, + ], + }, +}; + +export const SALES_DASHBOARD_PAGE: DashboardPageDefinition = { + type: 'dashboard', + header: { + title: 'Sales Dashboard', + }, + dashboard: SALES_DASHBOARD_DATA, +}; diff --git a/packages/twenty-website-redone/src/app-preview/data/shared-asset-urls.ts b/packages/twenty-website-redone/src/app-preview/data/shared-asset-urls.ts new file mode 100644 index 0000000000..d8010c7716 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/shared-asset-urls.ts @@ -0,0 +1,68 @@ +// The mockup's people avatars and company logos (referenced-only copies of +// the old site's shared assets). One pile, one import path. +// Inferred literal keys: a consumer reaching for a missing avatar is a +// compile error, never a silent undefined src (that bug shipped — three +// familiar-interface avatars rendered blank behind a Record). +const PEOPLE_AVATARS = { + anonymousFelix: '/images/shared/people/avatars/anonymous-felix.webp', + anonymousIndira: '/images/shared/people/avatars/anonymous-indira.webp', + anonymousLaura: '/images/shared/people/avatars/anonymous-laura.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', + 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', + katherineAdams: '/images/shared/people/avatars/katherine-adams.webp', + joeGebbia: '/images/shared/people/avatars/joe-gebbia.webp', + anonymousMike: '/images/shared/people/avatars/anonymous-mike.webp', + patrickCollison: '/images/shared/people/avatars/patrick-collison.webp', + peterReinhardt: '/images/shared/people/avatars/peter-reinhardt.webp', + peterThiel: '/images/shared/people/avatars/peter-thiel.webp', + pingLi: '/images/shared/people/avatars/ping-li.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', + stewartButterfield: '/images/shared/people/avatars/stewart-butterfield.webp', + sundarPichai: '/images/shared/people/avatars/sundar-pichai.webp', + thomasDohmke: '/images/shared/people/avatars/thomas-dohmke.webp', +}; + +const COMPANY_LOGOS_BY_DOMAIN: Record = { + 'accel.com': '/images/shared/companies/logos/accel.webp', + 'airbnb.com': '/images/shared/companies/logos/airbnb.webp', + 'google.com': '/images/shared/companies/logos/google.webp', + 'cursor.com': '/images/shared/companies/logos/cursor.webp', + 'linear.app': '/images/shared/companies/logos/linear.svg', + 'sequoia.com': '/images/shared/companies/logos/sequoia.webp', + 'sequoiacap.com': '/images/shared/companies/logos/sequoia.webp', + // Exported from src/icons/twenty-logo.tsx (the data layer loads + // brand images by URL; the component stays the vector's home). + 'twenty.com': '/images/shared/companies/logos/twenty.svg', + 'anthropic.com': '/images/shared/companies/logos/anthropic.webp', + 'figma.com': '/images/shared/companies/logos/figma.webp', + 'github.com': '/images/shared/companies/logos/github.webp', + 'linkedin.com': '/images/shared/companies/logos/linkedin.webp', + 'mailchimp.com': '/images/shared/companies/logos/mailchimp.webp', + 'notion.com': '/images/shared/companies/logos/notion.webp', + 'slack.com': '/images/shared/companies/logos/slack.webp', + 'stripe.com': '/images/shared/companies/logos/stripe.webp', +}; + +export const sharedAssetUrls = { + peopleAvatars: PEOPLE_AVATARS, + companyLogoForDomain: (domainName?: string): string | undefined => { + if (!domainName) { + return undefined; + } + const sanitizedDomain = domainName + .replace(/(https?:\/\/)|(www\.)/g, '') + .replace(/\/.*$/, '') + .toLowerCase(); + return COMPANY_LOGOS_BY_DOMAIN[sanitizedDomain]; + }, +}; diff --git a/packages/twenty-website-redone/src/app-preview/data/sidebar-config.ts b/packages/twenty-website-redone/src/app-preview/data/sidebar-config.ts new file mode 100644 index 0000000000..d40f85d118 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/sidebar-config.ts @@ -0,0 +1,72 @@ +// The mockup's sidebar, ported from the old app-preview data. +import { COMPANIES_TABLE_PAGE } from './companies-table-page'; +import { SALES_DASHBOARD_PAGE } from './sales-dashboard-page'; +import { WORKFLOWS_FOLDER } from './workflows-folder'; +import { DASHBOARDS_TABLE_PAGE } from './dashboards-table-page'; +import { NOTES_TABLE_PAGE } from './notes-table-page'; +import { OPPORTUNITY_KANBAN_PAGE } from './opportunity-kanban-page'; +import { PEOPLE_TABLE_PAGE } from './people-table-page'; +import { TASKS_TABLE_PAGE } from './tasks-table-page'; +import { type AppPreviewConfig } from '../types'; + +export const APP_PREVIEW_CONFIG: AppPreviewConfig = { + defaultViewbarActions: ['Filter', 'Sort', 'Options'], + sidebar: { + favorites: [ + { + id: 'sales-dashboard', + label: 'Sales Dashboard', + icon: { kind: 'avatar', label: 'S', tone: 'amber', shape: 'circle' }, + meta: 'Dashboard', + page: SALES_DASHBOARD_PAGE, + }, + ], + initialActiveItemId: 'companies', + initialOpenFolderIds: [], + workspace: [ + { + id: 'companies', + label: 'Companies', + icon: { kind: 'tabler', name: 'buildingSkyscraper', tone: 'blue' }, + page: COMPANIES_TABLE_PAGE, + }, + { + id: 'people', + label: 'People', + icon: { kind: 'tabler', name: 'user', tone: 'blue' }, + page: PEOPLE_TABLE_PAGE, + }, + { + id: 'opportunities', + label: 'Opportunities', + icon: { kind: 'tabler', name: 'targetArrow', tone: 'red' }, + page: OPPORTUNITY_KANBAN_PAGE, + }, + { + id: 'tasks', + label: 'Tasks', + icon: { kind: 'tabler', name: 'checkbox', tone: 'teal' }, + page: TASKS_TABLE_PAGE, + }, + { + id: 'notes', + label: 'Notes', + icon: { kind: 'tabler', name: 'notes', tone: 'teal' }, + page: NOTES_TABLE_PAGE, + }, + { + id: 'dashboards', + label: 'Dashboards', + icon: { kind: 'tabler', name: 'layoutDashboard', tone: 'gray' }, + page: DASHBOARDS_TABLE_PAGE, + }, + WORKFLOWS_FOLDER, + { + id: 'book-demo', + label: 'Book a demo', + href: 'https://cal.com/forms/f7841033-0a20-4958-8c92-4e34ec128a81', + icon: { kind: 'brand', brand: 'twenty', overlay: 'link' }, + }, + ], + }, +}; diff --git a/packages/twenty-website-redone/src/app-preview/data/tasks-table-page.ts b/packages/twenty-website-redone/src/app-preview/data/tasks-table-page.ts new file mode 100644 index 0000000000..6da93a1064 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/tasks-table-page.ts @@ -0,0 +1,76 @@ +// Extracted verbatim from the old data (the tasks table). +import { sharedAssetUrls } from './shared-asset-urls'; +import { type TablePageDefinition } from '../types'; + +const PEOPLE_AVATAR_URLS = sharedAssetUrls.peopleAvatars; + +export const TASKS_TABLE_PAGE: TablePageDefinition = { + type: 'table', + header: { + title: 'All Tasks', + count: 2, + }, + columns: [ + { id: 'title', label: 'Title', width: 220, isFirstColumn: true }, + { id: 'assignee', label: 'Assignee', width: 160 }, + { id: 'dueDate', label: 'Due Date', width: 160 }, + { id: 'relatedTo', label: 'Related To', width: 160 }, + { id: 'status', label: 'Status', width: 140 }, + ], + rows: [ + { + id: 'send-nda', + cells: { + title: { + type: 'text', + value: 'Send NDA', + shortLabel: 'S', + tone: 'teal', + }, + assignee: { + type: 'person', + name: 'Dario Amodei', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei, + }, + dueDate: { type: 'text', value: 'Oct 25, 2023' }, + relatedTo: { + type: 'entity', + name: 'Anthropic', + domain: 'anthropic.com', + }, + status: { type: 'select', value: 'To Do' }, + }, + }, + { + id: 'review-proposal', + cells: { + title: { + type: 'text', + value: 'Review proposal', + shortLabel: 'R', + tone: 'teal', + }, + assignee: { + type: 'person', + name: 'Stewart Butterfield', + tone: 'teal', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.stewartButterfield, + }, + dueDate: { type: 'text', value: 'Oct 28, 2023' }, + relatedTo: { + type: 'entity', + name: 'Slack', + domain: 'slack.com', + }, + status: { + type: 'select', + color: 'blue', + value: 'In Progress', + }, + }, + }, + ], +}; diff --git a/packages/twenty-website-redone/src/app-preview/data/workflows-folder.ts b/packages/twenty-website-redone/src/app-preview/data/workflows-folder.ts new file mode 100644 index 0000000000..34ba9855fb --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/data/workflows-folder.ts @@ -0,0 +1,266 @@ +// The Workflows folder — the two named workflows and the three list +// tables, extracted verbatim from the old data. +import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones'; + +import { sharedAssetUrls } from './shared-asset-urls'; +import { + type NavbarAction, + type SidebarFolderDef, + type TablePageDefinition, +} from '../types'; + +const NAMED_WORKFLOW_NAVBAR_ACTIONS = [ + { icon: 'chevronDown', variant: 'icon' }, + { icon: 'chevronUp', variant: 'icon' }, + { icon: 'heart', variant: 'icon' }, + { icon: 'playerPause', label: 'Deactivate' }, + { icon: 'repeat', label: 'See Runs' }, + { icon: 'plus', label: 'Add a Node' }, + { icon: 'dotsVertical', trailingLabel: '\u2318K' }, +] satisfies NavbarAction[]; + +const PEOPLE_AVATAR_URLS = sharedAssetUrls.peopleAvatars; + +const WORKFLOW_LIST_TABLE: TablePageDefinition = { + type: 'table', + header: { + title: 'All Workflows', + count: 2, + }, + columns: [ + { + id: 'name', + label: 'Name', + width: 240, + isFirstColumn: true, + }, + { id: 'status', label: 'Status', width: 140 }, + { id: 'lastRun', label: 'Last Run', width: 200 }, + ], + rows: [ + { + id: 'create-company-when-adding-a-new-person', + cells: { + name: { + type: 'text', + value: 'Create company when adding a new person', + shortLabel: 'C', + tone: 'orange', + }, + status: { + type: 'select', + color: 'green', + value: 'Active', + }, + lastRun: { type: 'text', value: 'Oct 24, 2023 10:00 am' }, + }, + }, + { + id: 'nurture', + cells: { + name: { + type: 'text', + value: 'Nurture Sequence', + shortLabel: 'N', + tone: 'amber', + }, + status: { type: 'select', value: 'Inactive' }, + lastRun: { type: 'text', value: 'Oct 20, 2023 3:15 pm' }, + }, + }, + ], +}; + +const WORKFLOW_RUNS_TABLE: TablePageDefinition = { + type: 'table', + header: { + title: 'All Runs', + count: 2, + }, + columns: [ + { + id: 'runId', + label: 'Run ID', + width: 160, + isFirstColumn: true, + }, + { id: 'workflow', label: 'Workflow', width: 200 }, + { id: 'status', label: 'Status', width: 120 }, + { id: 'startedAt', label: 'Started At', width: 200 }, + { id: 'duration', label: 'Duration', width: 120 }, + ], + rows: [ + { + id: 'run-12345', + cells: { + runId: { + type: 'text', + value: 'run_12345', + shortLabel: 'R', + tone: 'amber', + }, + workflow: { type: 'text', value: 'New Lead Assignment' }, + status: { + type: 'select', + color: 'green', + value: 'Success', + }, + startedAt: { + type: 'text', + value: 'Oct 24, 2023 10:00 am', + }, + duration: { type: 'text', value: '2s' }, + }, + }, + { + id: 'run-12346', + cells: { + runId: { + type: 'text', + value: 'run_12346', + shortLabel: 'R', + tone: 'amber', + }, + workflow: { type: 'text', value: 'Nurture Sequence' }, + status: { type: 'select', color: 'red', value: 'Failed' }, + startedAt: { + type: 'text', + value: 'Oct 20, 2023 3:15 pm', + }, + duration: { type: 'text', value: '5s' }, + }, + }, + ], +}; + +const WORKFLOW_VERSIONS_TABLE: TablePageDefinition = { + type: 'table', + header: { + title: 'All Versions', + count: 2, + }, + columns: [ + { + id: 'version', + label: 'Version', + width: 120, + isFirstColumn: true, + }, + { id: 'workflow', label: 'Workflow', width: 200 }, + { id: 'publishedAt', label: 'Published At', width: 200 }, + { id: 'publishedBy', label: 'Published By', width: 160 }, + ], + rows: [ + { + id: 'v2-lead', + cells: { + version: { + type: 'text', + value: 'v2', + shortLabel: 'V', + tone: 'amber', + }, + workflow: { type: 'text', value: 'New Lead Assignment' }, + publishedAt: { + type: 'text', + value: 'Oct 15, 2023 9:00 am', + }, + publishedBy: { + type: 'person', + name: 'Ivan Zhao', + shortLabel: 'I', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao, + }, + }, + }, + { + id: 'v1-lead', + cells: { + version: { + type: 'text', + value: 'v1', + shortLabel: 'V', + tone: 'amber', + }, + workflow: { type: 'text', value: 'New Lead Assignment' }, + publishedAt: { + type: 'text', + value: 'Sep 10, 2023 1:00 pm', + }, + publishedBy: { + type: 'person', + name: 'Ivan Zhao', + shortLabel: 'I', + tone: 'gray', + kind: 'person', + avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao, + }, + }, + }, + ], +}; + +export const WORKFLOWS_FOLDER: SidebarFolderDef = { + id: 'workflows', + label: 'Workflows', + icon: { kind: 'tabler', name: 'settingsAutomation', tone: 'orange' }, + items: [ + { + id: 'workflow-create-company-when-adding-a-new-person', + label: 'Create company when adding a new person', + icon: { + color: APP_PREVIEW_TONES.workflowAvatarInk, + kind: 'avatar', + label: 'C', + tone: 'orange', + shape: 'circle', + }, + page: { + type: 'workflow', + header: { + navbarActions: NAMED_WORKFLOW_NAVBAR_ACTIONS, + title: 'Create company when adding a new person', + }, + }, + }, + { + id: 'workflow-send-email-sequence', + hidden: true, + label: 'Send email sequence when deal is engaged', + icon: { + color: APP_PREVIEW_TONES.workflowAvatarInk, + kind: 'avatar', + label: 'S', + tone: 'orange', + shape: 'circle', + }, + page: { + type: 'workflow', + header: { + navbarActions: NAMED_WORKFLOW_NAVBAR_ACTIONS, + title: 'Send email sequence when deal is engaged', + }, + }, + }, + { + id: 'workflow-list', + label: 'All Workflows', + icon: { kind: 'tabler', name: 'settingsAutomation', tone: 'gray' }, + page: WORKFLOW_LIST_TABLE, + }, + { + id: 'workflow-runs', + label: 'Workflows runs', + icon: { kind: 'tabler', name: 'playerPlay', tone: 'gray' }, + page: WORKFLOW_RUNS_TABLE, + }, + { + id: 'workflow-versions', + label: 'Workflows versions', + icon: { kind: 'tabler', name: 'versions', tone: 'gray' }, + page: WORKFLOW_VERSIONS_TABLE, + }, + ], +}; diff --git a/packages/twenty-website-redone/src/app-preview/pages/RenderPage.tsx b/packages/twenty-website-redone/src/app-preview/pages/RenderPage.tsx new file mode 100644 index 0000000000..a146444dc2 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/RenderPage.tsx @@ -0,0 +1,34 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +import { KanbanPage } from './kanban/KanbanPage'; +import { RecordPage } from './record/RecordPage'; +import { TablePage } from './table/TablePage'; +import { WorkflowPage } from './workflow/WorkflowPage'; +import { type PageDefinition } from '../types'; + +// The dashboard (charts) is the heaviest page and never the landing view: +// it stays a deferred chunk, idle-preloaded by its hosts after mount. +const DashboardPage = dynamic( + () => + import('./dashboard/DashboardPage').then((module) => module.DashboardPage), + { ssr: false }, +); + +export function renderPage(page: PageDefinition) { + switch (page.type) { + case 'table': + return ; + case 'kanban': + return ; + case 'workflow': + return ; + case 'dashboard': + return ; + case 'record': + return ; + default: + return null; + } +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/dashboard/DashboardCharts.tsx b/packages/twenty-website-redone/src/app-preview/pages/dashboard/DashboardCharts.tsx new file mode 100644 index 0000000000..a131bd6657 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/dashboard/DashboardCharts.tsx @@ -0,0 +1,405 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { useEffect, useId, useState, type CSSProperties } from 'react'; + +import { createAnimationFrameLoop } from '@/platform/motion'; +import { EASING, REDUCED_MOTION } from '@/tokens'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones'; + +import { + type DashboardBarChart as DashboardBarChartData, + type DashboardDonutChart as DashboardDonutChartData, + type DashboardLineChart as DashboardLineChartData, +} from '../../types'; + +const ACCENT = APP_PREVIEW_TONES.dashboardChart.accent; + +const ChartFrame = styled.div` + display: flex; + flex: 1; + min-height: 0; + width: 100%; +`; + +const AxisLabel = styled.span` + color: ${THEME_LIGHT.font.color.secondary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 10px; + line-height: 1; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const PlotArea = styled.div` + display: flex; + flex: 1; + min-height: 0; + position: relative; +`; + +const Gridlines = styled.div<{ $bottom: number }>` + bottom: ${({ $bottom }) => `${$bottom}px`}; + display: flex; + flex-direction: column; + justify-content: space-between; + left: 0; + pointer-events: none; + position: absolute; + right: 0; + top: 0; +`; + +const GridLine = styled.span` + border-top: 1px dashed ${THEME_LIGHT.border.color.light}; + display: block; + width: 100%; +`; + +const GRID_LINES = [0, 1, 2, 3]; + +const LINE_W = 320; +const LINE_H = 120; +const LINE_PAD = 10; + +const LineSvg = styled.svg` + display: block; + height: 100%; + position: relative; + width: 100%; + z-index: 1; + + .line-stroke { + transition: opacity 420ms ease; + } + + .line-area { + transition: opacity 500ms ease 160ms; + } + + ${REDUCED_MOTION} { + .line-area, + .line-stroke { + transition: none; + } + } +`; + +function DashboardLineChart({ data }: { data: DashboardLineChartData }) { + const [drawn, setDrawn] = useState(false); + const gradientId = `dashboard-line-fill-${useId().replace(/:/g, '')}`; + + useEffect(() => { + const task = createAnimationFrameLoop({ + onFrame: () => { + setDrawn(true); + return false; + }, + }); + task.start(); + return () => task.stop(); + }, []); + + const max = Math.max(...data.values); + const min = Math.min(...data.values); + const range = max - min || 1; + const stepX = (LINE_W - LINE_PAD * 2) / (data.values.length - 1); + const points = data.values.map((value, index) => { + const x = LINE_PAD + index * stepX; + const y = LINE_PAD + (LINE_H - LINE_PAD * 2) * (1 - (value - min) / range); + return [x, y] as [number, number]; + }); + const linePath = points + .map( + ([x, y], index) => + `${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`, + ) + .join(' '); + const lastX = points[points.length - 1][0]; + const firstX = points[0][0]; + const areaPath = `${linePath} L${lastX.toFixed(1)} ${LINE_H - LINE_PAD} L${firstX.toFixed(1)} ${LINE_H - LINE_PAD} Z`; + + return ( + + + + {GRID_LINES.map((line) => ( + + ))} + + + + + + + + + + + + + + ); +} + +const BarColumns = styled.div` + align-items: flex-end; + display: flex; + flex: 1; + gap: 10px; + min-height: 0; + padding-top: 8px; + position: relative; + width: 100%; +`; + +const BarColumn = styled.div` + align-items: center; + display: flex; + flex: 1; + flex-direction: column; + gap: 6px; + justify-content: flex-end; + min-width: 0; +`; + +const BarTrack = styled.div` + align-items: flex-end; + display: flex; + flex: 1; + min-height: 0; + width: 100%; +`; + +const Bar = styled.div<{ $heightPct: number; $index: number }>` + animation: barGrow 460ms ${EASING.standard} both; + animation-delay: ${({ $index }) => `${$index * 90}ms`}; + background: ${ACCENT}; + border-radius: ${THEME_LIGHT.border.radius.sm} ${THEME_LIGHT.border.radius.sm} + 0 0; + height: ${({ $heightPct }) => `${$heightPct}%`}; + margin: 0 auto; + max-width: 28px; + transform-origin: bottom; + width: 100%; + + @keyframes barGrow { + from { + transform: scaleY(0); + } + to { + transform: scaleY(1); + } + } + + ${REDUCED_MOTION} { + animation: none; + } +`; + +function DashboardBarChart({ data }: { data: DashboardBarChartData }) { + const max = Math.max(...data.bars.map((bar) => bar.value)) || 1; + return ( + + + + {GRID_LINES.map((line) => ( + + ))} + + + {data.bars.map((bar, index) => ( + + + + + {bar.label} + + ))} + + + + ); +} + +const DONUT_RADIUS = 15.915; +const DONUT_GAP = 2; + +const DonutWrap = styled.div` + align-items: center; + display: flex; + flex: 1; + gap: 12px; + min-height: 0; +`; + +const DonutFigure = styled.div` + flex-shrink: 0; + height: 96px; + position: relative; + width: 96px; +`; + +const DonutSvg = styled.svg` + display: block; + height: 100%; + transform: rotate(-90deg); + width: 100%; + + .donut-slice { + animation: donutSliceDraw 520ms ${EASING.standard} both; + } + + @keyframes donutSliceDraw { + from { + stroke-dasharray: 0 100; + } + to { + stroke-dasharray: var(--slice-pct) var(--slice-rest); + } + } + + ${REDUCED_MOTION} { + .donut-slice { + animation: none; + } + } +`; + +const DonutCenter = styled.div` + align-items: center; + display: flex; + flex-direction: column; + inset: 0; + justify-content: center; + position: absolute; +`; + +const DonutValue = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 22px; + font-weight: ${THEME_LIGHT.font.weight.semiBold}; + line-height: 1; +`; + +const DonutLabel = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 11px; + line-height: 1.4; +`; + +const DonutLegend = styled.div` + display: flex; + flex: 1; + flex-direction: column; + gap: 6px; + min-width: 0; +`; + +const LegendRow = styled.div` + align-items: center; + display: flex; + gap: 6px; + min-width: 0; +`; + +const LegendDot = styled.span<{ $color: string }>` + background: ${({ $color }) => $color}; + border-radius: ${THEME_LIGHT.border.radius.xs}; + flex-shrink: 0; + height: 8px; + width: 8px; +`; + +const LegendLabel = styled.span` + color: ${THEME_LIGHT.font.color.secondary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +function DashboardDonutChart({ data }: { data: DashboardDonutChartData }) { + const total = data.slices.reduce((sum, slice) => sum + slice.value, 0) || 1; + let cumulative = 0; + return ( + + + + {data.slices.map((slice, index) => { + const pct = (slice.value / total) * 100; + const drawn = Math.max(pct - DONUT_GAP, 0.5); + const offset = -cumulative; + cumulative += pct; + return ( + + ); + })} + + + {data.centerValue} + {data.centerLabel} + + + + {data.slices.map((slice) => ( + + + {slice.label} + + ))} + + + ); +} + +export const DASHBOARD_CHARTS = { + Line: DashboardLineChart, + Bar: DashboardBarChart, + Donut: DashboardDonutChart, +}; diff --git a/packages/twenty-website-redone/src/app-preview/pages/dashboard/DashboardPage.tsx b/packages/twenty-website-redone/src/app-preview/pages/dashboard/DashboardPage.tsx new file mode 100644 index 0000000000..b9b73d3558 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/dashboard/DashboardPage.tsx @@ -0,0 +1,230 @@ +import { styled } from '@linaria/react'; +import { IconTrendingDown, IconTrendingUp } from '@tabler/icons-react'; + +import { EASING, mediaUp, REDUCED_MOTION } from '@/tokens'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { previewFontSize } from '@/app-preview/preview-font-size'; +import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones'; + +import { DASHBOARD_CHARTS } from './DashboardCharts'; +import { PREVIEW_SKELETON } from '../../primitives/PreviewSkeleton'; +import { type DashboardKpi, type DashboardPageDefinition } from '../../types'; + +const DashboardGrid = styled.div` + display: grid; + gap: 8px; + grid-template-areas: + 'kpis' + 'line' + 'bar' + 'donut'; + grid-template-columns: minmax(0, 1fr); + height: 100%; + min-height: 0; + padding: 8px; + + ${mediaUp('md')} { + grid-template-areas: + 'kpis line line' + 'bar bar donut'; + grid-template-columns: minmax(176px, 248px) minmax(0, 1fr) minmax( + 176px, + 248px + ); + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + } +`; + +const WidgetCard = styled.div` + animation: dashboardWidgetAppear 360ms ${EASING.standard} both; + background: ${THEME_LIGHT.background.secondary}; + border: 1px solid ${THEME_LIGHT.border.color.light}; + border-radius: ${THEME_LIGHT.border.radius.md}; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + overflow: hidden; + padding: 8px; + + @keyframes dashboardWidgetAppear { + from { + opacity: 0; + transform: translateY(6px) scale(0.99); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + ${REDUCED_MOTION} { + animation: none; + } +`; + +const WidgetTitle = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + flex-shrink: 0; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.medium}; + line-height: 1.4; + overflow: hidden; + padding: 0 2px; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const KpiStack = styled.div` + display: grid; + gap: 8px; + grid-area: kpis; + min-width: 0; + + ${mediaUp('md')} { + grid-template-rows: repeat(3, minmax(0, 1fr)); + } +`; + +const KpiCard = styled(WidgetCard)` + gap: 6px; + justify-content: center; +`; + +const KpiValueRow = styled.div` + align-items: baseline; + display: flex; + gap: 8px; + justify-content: space-between; +`; + +const KpiValue = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 25px; + font-weight: ${THEME_LIGHT.font.weight.semiBold}; + line-height: 1.1; +`; + +const KpiTrend = styled.span<{ $up: boolean }>` + align-items: center; + color: ${({ $up }) => + $up + ? APP_PREVIEW_TONES.dashboardChart.trendUp + : APP_PREVIEW_TONES.dashboardChart.trendDown}; + display: inline-flex; + flex-shrink: 0; + font-family: ${THEME_LIGHT.font.family}; + font-size: 12px; + font-weight: ${THEME_LIGHT.font.weight.medium}; + gap: 2px; +`; + +const LineCard = styled(WidgetCard)` + grid-area: line; +`; + +const BarCard = styled(WidgetCard)` + grid-area: bar; +`; + +const DonutCard = styled(WidgetCard)` + grid-area: donut; +`; + +function KpiWidget({ kpi }: { kpi: DashboardKpi }) { + const isUp = kpi.trend?.direction === 'up'; + return ( + + {kpi.title} + + {kpi.value} + {kpi.trend ? ( + + {isUp ? ( + + ) : ( + + )} + {kpi.trend.value} + + ) : null} + + + ); +} + +function KpiSkeleton() { + return ( + + + + + ); +} + +function ChartSkeleton() { + return ( + <> + + + + ); +} + +export function DashboardPage({ page }: { page: DashboardPageDefinition }) { + const { kpis, lineChart, barChart, donutChart, generating } = page.dashboard; + return ( + + {kpis.length > 0 ? ( + + {kpis.map((kpi) => + generating ? ( + + ) : ( + + ), + )} + + ) : null} + {lineChart ? ( + + {generating ? ( + + ) : ( + <> + {lineChart.title} + + + )} + + ) : null} + {barChart ? ( + + {generating ? ( + + ) : ( + <> + {barChart.title} + + + )} + + ) : null} + {donutChart ? ( + + {generating ? ( + + ) : ( + <> + {donutChart.title} + + + )} + + ) : null} + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/kanban/KanbanCard.tsx b/packages/twenty-website-redone/src/app-preview/pages/kanban/KanbanCard.tsx new file mode 100644 index 0000000000..2a0619dae9 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/kanban/KanbanCard.tsx @@ -0,0 +1,292 @@ +import { styled } from '@linaria/react'; +import { + IconBuildingSkyscraper, + IconCalendarEvent, + IconCheck, + IconCurrencyDollar, + IconId, + IconStar, + IconUser, + IconUserCircle, +} from '@tabler/icons-react'; +import { type ComponentType, type ReactNode } from 'react'; + +import { RatingStar } from '@/icons'; +import { EASING } from '@/tokens'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { previewFontSize } from '@/app-preview/preview-font-size'; + +import { Chip } from '../../primitives/Chip'; +import { FaviconLogo } from '../../primitives/FaviconLogo'; +import { PersonAvatar } from '../../primitives/PersonAvatar'; +import { PreviewAvatar } from '../../primitives/PreviewAvatar'; +import { + type CellEntity, + type CellPerson, + type KanbanCard as KanbanCardData, +} from '../../types'; + +// twenty-front hashes a record's identifier to its avatar tone; the mockup +// does the same so each opportunity gets a stable, distinct color. +const TITLE_AVATAR_TONES = [ + 'blue', + 'green', + 'purple', + 'pink', + 'orange', + 'red', + 'amber', + 'teal', +]; + +function toneForTitle(title: string): string { + const hash = [...title].reduce( + (total, char) => total + char.charCodeAt(0), + 0, + ); + return TITLE_AVATAR_TONES[hash % TITLE_AVATAR_TONES.length]; +} + +const Card = styled.div` + animation: kanbanCardAppear 320ms ${EASING.standard} both; + background: ${THEME_LIGHT.background.secondary}; + border: 1px solid ${THEME_LIGHT.border.color.medium}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + display: flex; + flex-direction: column; + overflow: hidden; + + @keyframes kanbanCardAppear { + from { + opacity: 0; + transform: translateY(6px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +`; + +const CardHeader = styled.div` + align-items: center; + display: flex; + gap: 8px; + padding: 8px 8px 4px; +`; + +// The identifier renders as twenty-front's RecordChip: a rounded initial +// avatar (the record's hashed tone) + the name at regular weight in a +// transparent chip. The slot flexes so the name ellipsizes and the selection +// checkbox (only shown on selected cards) sits at the right. +const TitleSlot = styled.div` + display: flex; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +`; + +const CheckboxContainer = styled.div` + align-items: center; + display: flex; + flex: 0 0 24px; + height: 24px; + justify-content: center; + width: 24px; +`; + +const CheckboxBox = styled.div<{ $checked?: boolean }>` + align-items: center; + background: ${({ $checked }) => + $checked ? THEME_LIGHT.background.transparent.blue : 'transparent'}; + border: 1px solid + ${({ $checked }) => + $checked + ? THEME_LIGHT.border.color.blue + : THEME_LIGHT.border.color.strong}; + border-radius: 3px; + color: ${THEME_LIGHT.font.color.secondary}; + display: flex; + height: 14px; + justify-content: center; + width: 14px; +`; + +const CardFields = styled.div` + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 8px 8px 10px; +`; + +const FieldRowShell = styled.div` + align-items: center; + display: flex; + gap: 4px; + min-height: 24px; + width: 100%; +`; + +const FieldIcon = styled.div` + align-items: center; + color: ${THEME_LIGHT.font.color.tertiary}; + display: flex; + flex: 0 0 16px; + height: 16px; + justify-content: center; + width: 16px; +`; + +const FieldValueWrap = styled.div` + align-items: center; + display: flex; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +`; + +const FieldText = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.regular}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const StarsRow = styled.div` + align-items: center; + display: inline-flex; + gap: 2px; + padding: 0 4px; +`; + +const StarGlyph = styled.span` + align-items: center; + display: inline-flex; + height: 12px; + justify-content: center; + width: 12px; +`; + +function EntityChip({ entity }: { entity: CellEntity }) { + return ( + } + maxWidth={152} + variant="highlighted" + /> + ); +} + +function PersonChip({ person }: { person: CellPerson }) { + return ( + } + maxWidth={152} + variant="highlighted" + /> + ); +} + +function FieldRow({ + icon: Icon, + children, +}: { + icon: ComponentType<{ + 'aria-hidden'?: boolean; + color?: string; + size?: number; + stroke?: number; + }>; + children: ReactNode; +}) { + return ( + + + + + {children} + + ); +} + +export function KanbanCard({ card }: { card: KanbanCardData }) { + return ( + + + + + {card.title.trim().charAt(0).toUpperCase()} + + } + variant="transparent" + /> + + {card.checked ? ( + + + + + + ) : null} + + + + {card.amount} + + + + + + + + + + {Array.from({ length: 5 }, (_, index) => ( + + + + ))} + + + + {card.date} + + + + + + {card.recordId} + + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/kanban/KanbanLane.tsx b/packages/twenty-website-redone/src/app-preview/pages/kanban/KanbanLane.tsx new file mode 100644 index 0000000000..98b84ee5d0 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/kanban/KanbanLane.tsx @@ -0,0 +1,192 @@ +import { styled } from '@linaria/react'; +import { IconPlus } from '@tabler/icons-react'; + +import { EASING } from '@/tokens'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { previewFontSize } from '@/app-preview/preview-font-size'; + +import { KanbanCard } from './KanbanCard'; +import { MiniIcon } from '../../primitives/MiniIcon'; +import { PREVIEW_SKELETON } from '../../primitives/PreviewSkeleton'; +import { type KanbanLane as KanbanLaneData } from '../../types'; + +const Lane = styled.div<{ $index: number; $last?: boolean }>` + animation: kanbanLaneAppear 420ms ${EASING.standard} both; + animation-delay: ${({ $index }) => `${120 + $index * 80}ms`}; + border-right: ${({ $last }) => + $last ? 'none' : `1px solid ${THEME_LIGHT.border.color.light}`}; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + + @keyframes kanbanLaneAppear { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const LaneHeader = styled.div` + align-items: center; + display: flex; + gap: 4px; + min-height: 40px; + padding: 8px; +`; + +// A kanban column groups a Select field, so the product renders each column's +// stage as a Tag — color3 surface, color11 text, regular weight — keyed by the +// option color. The values bake from the generated theme (gray is the base and +// the unknown-tone fallback the old hand-mixed table carried). +const LaneTag = styled.span` + align-items: center; + background: ${THEME_LIGHT.tag.background.gray}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${THEME_LIGHT.tag.text.gray}; + display: inline-flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.regular}; + height: 20px; + line-height: 1.4; + padding: 0 8px; + white-space: nowrap; + + &[data-tone='blue'] { + background: ${THEME_LIGHT.tag.background.blue}; + color: ${THEME_LIGHT.tag.text.blue}; + } + &[data-tone='green'] { + background: ${THEME_LIGHT.tag.background.green}; + color: ${THEME_LIGHT.tag.text.green}; + } + &[data-tone='pink'] { + background: ${THEME_LIGHT.tag.background.pink}; + color: ${THEME_LIGHT.tag.text.pink}; + } + &[data-tone='purple'] { + background: ${THEME_LIGHT.tag.background.purple}; + color: ${THEME_LIGHT.tag.text.purple}; + } +`; + +const LaneCount = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.regular}; + line-height: 1.4; + white-space: nowrap; +`; + +const LaneBody = styled.div` + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; + min-height: 0; + padding: 0 8px 8px; +`; + +const AddCardButton = styled.div` + align-items: center; + color: ${THEME_LIGHT.font.color.tertiary}; + display: inline-flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.regular}; + gap: 4px; + height: 24px; + line-height: 1.4; + padding: 0 4px; + white-space: nowrap; +`; + +const SkeletonCardShell = styled.div<{ $index: number }>` + animation: kanbanSkeletonCardAppear 320ms ${EASING.standard} both; + animation-delay: ${({ $index }) => `${$index * 90}ms`}; + background: ${THEME_LIGHT.background.secondary}; + border: 1px solid ${THEME_LIGHT.border.color.medium}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 8px; + + @keyframes kanbanSkeletonCardAppear { + from { + opacity: 0; + transform: translateY(6px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +`; + +const SkeletonCardField = styled.div` + align-items: center; + display: flex; + gap: 6px; + width: 100%; +`; + +const SKELETON_FIELD_WIDTHS = ['70%', '54%', '62%']; + +function SkeletonCard({ index }: { index: number }) { + return ( + + + {SKELETON_FIELD_WIDTHS.map((width) => ( + + + + + ))} + + ); +} + +export function KanbanLane({ + lane, + index = 0, + isLast, + generating = false, +}: { + generating?: boolean; + index?: number; + isLast: boolean; + lane: KanbanLaneData; +}) { + const skeletonCardCount = 2 + (index % 2); + return ( + + + {lane.label} + {generating ? null : {lane.cards.length}} + + + {generating + ? Array.from({ length: skeletonCardCount }, (_, cardIndex) => ( + + )) + : lane.cards.map((card) => )} + + + New + + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/kanban/KanbanPage.tsx b/packages/twenty-website-redone/src/app-preview/pages/kanban/KanbanPage.tsx new file mode 100644 index 0000000000..016fed047b --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/kanban/KanbanPage.tsx @@ -0,0 +1,54 @@ +import { styled } from '@linaria/react'; + +import { KanbanLane } from './KanbanLane'; +import { type KanbanPageDefinition } from '../../types'; + +// The product's RECORD_BOARD_COLUMN_WIDTH. +const KANBAN_LANE_WIDTH_PX = 200; + +const BoardShell = styled.div` + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: auto; + scrollbar-width: none; + width: 100%; + + &::-webkit-scrollbar { + display: none; + } +`; + +const BoardCanvas = styled.div<{ $laneCount: number }>` + box-sizing: border-box; + display: grid; + grid-template-columns: repeat( + ${({ $laneCount }) => $laneCount}, + minmax(${KANBAN_LANE_WIDTH_PX}px, 1fr) + ); + min-height: 100%; + min-width: ${({ $laneCount }) => + `max(100%, ${$laneCount * KANBAN_LANE_WIDTH_PX + 16}px)`}; + padding: 0 8px; + width: 100%; +`; + +export function KanbanPage({ page }: { page: KanbanPageDefinition }) { + return ( + + + {page.lanes.map((lane, index) => ( + + ))} + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/record/AvatarGroup.tsx b/packages/twenty-website-redone/src/app-preview/pages/record/AvatarGroup.tsx new file mode 100644 index 0000000000..4e8638a311 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/record/AvatarGroup.tsx @@ -0,0 +1,32 @@ +import { styled } from '@linaria/react'; + +import { PersonAvatar } from '../../primitives/PersonAvatar'; +import { type RecordParticipant } from '../../types'; + +const AvatarStack = styled.div` + align-items: center; + display: flex; + flex-shrink: 0; +`; + +const AvatarWrap = styled.div<{ $index: number }>` + margin-left: ${({ $index }) => ($index === 0 ? '0' : '-4px')}; +`; + +export function AvatarGroup({ + people, + size, +}: { + people: RecordParticipant[]; + size: number; +}) { + return ( + + {people.slice(0, 3).map((person, index) => ( + + + + ))} + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/record/RecordCalendar.tsx b/packages/twenty-website-redone/src/app-preview/pages/record/RecordCalendar.tsx new file mode 100644 index 0000000000..e93a325ae1 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/record/RecordCalendar.tsx @@ -0,0 +1,169 @@ +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { IconArrowRight, IconPlus } from '@tabler/icons-react'; + +import { EASING } from '@/tokens'; + +import { type RecordCalendarDay } from '../../types'; +import { AvatarGroup } from './AvatarGroup'; +import { RECORD_PANEL_CHROME } from './RecordPanelChrome'; + +const CalendarDayRow = styled.div<{ $index: number }>` + align-items: flex-start; + animation: calendarDayAppear 360ms ${EASING.standard} both; + animation-delay: ${({ $index }) => `${120 + $index * 70}ms`}; + display: flex; + gap: 12px; + padding: 8px 12px; + + & + & { + border-top: 1px solid ${THEME_LIGHT.border.color.light}; + } + + @keyframes calendarDayAppear { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const DayBadge = styled.div` + flex-shrink: 0; + text-align: center; + width: 28px; +`; + +const WeekDay = styled.div` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +`; + +const MonthDay = styled.div` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 14px; + font-weight: 500; +`; + +const DayEvents = styled.div` + display: flex; + flex: 1; + flex-direction: column; + gap: 10px; + min-width: 0; +`; + +const CalEventRow = styled.div` + align-items: center; + display: flex; + gap: 12px; + height: 24px; +`; + +const AttendanceBar = styled.span<{ $active?: boolean }>` + background: ${({ $active }) => + $active ? THEME_LIGHT.accent.accent9 : THEME_LIGHT.border.color.strong}; + border-radius: ${THEME_LIGHT.border.radius.xs}; + flex-shrink: 0; + height: 24px; + width: 4px; +`; + +const CalLabels = styled.div` + align-items: center; + display: flex; + flex: 1; + gap: 8px; + min-width: 0; +`; + +const CalTime = styled.div` + align-items: center; + color: ${THEME_LIGHT.font.color.tertiary}; + display: flex; + flex-shrink: 0; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + gap: 4px; +`; + +const CalTitle = styled.div` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const { + ListCard, + TabAddButton, + TabHeader, + TabHeaderCount, + TabHeaderLabel, + TabHeaderTitle, + TabSection, +} = RECORD_PANEL_CHROME; + +export function RecordCalendar({ + calendar, +}: { + calendar: RecordCalendarDay[]; +}) { + return ( + + + + June + + {calendar.reduce((total, day) => total + day.events.length, 0)} + + + + + Add event + + + + {calendar.map((day, index) => ( + + + {day.weekday} + {day.day} + + + {day.events.map((event) => ( + + + + + {event.start} + + {event.end} + + {event.title} + + + + ))} + + + ))} + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/record/RecordEmails.tsx b/packages/twenty-website-redone/src/app-preview/pages/record/RecordEmails.tsx new file mode 100644 index 0000000000..8db22f76ea --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/record/RecordEmails.tsx @@ -0,0 +1,113 @@ +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { IconPlus } from '@tabler/icons-react'; + +import { type RecordEmail } from '../../types'; +import { AvatarGroup } from './AvatarGroup'; +import { RECORD_PANEL_CHROME } from './RecordPanelChrome'; + +const EmailHeading = styled.div` + align-items: center; + display: flex; + flex-shrink: 0; + max-width: 34%; + overflow: hidden; +`; + +const SenderNames = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + margin: 0 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const ThreadCount = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; +`; + +const SubjectBody = styled.div` + align-items: center; + display: flex; + flex: 1; + gap: 8px; + overflow: hidden; +`; + +const EmailSubject = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const EmailBody = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + flex: 1; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const ReceivedAt = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + flex-shrink: 0; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + padding: 0 4px; + white-space: nowrap; +`; + +const { + ActivityRowBox, + ListCard, + TabAddButton, + TabHeader, + TabHeaderCount, + TabHeaderLabel, + TabHeaderTitle, + TabSection, +} = RECORD_PANEL_CHROME; + +export function RecordEmails({ emails }: { emails: RecordEmail[] }) { + return ( + + + + Inbox + {emails.length} + + + + Compose + + + + {emails.map((email, index) => ( + + + + + {email.participants.map((person) => person.name).join(', ')} + + {email.count} + + + {email.subject} + {email.body} + + {email.date} + + ))} + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/record/RecordFieldValue.tsx b/packages/twenty-website-redone/src/app-preview/pages/record/RecordFieldValue.tsx new file mode 100644 index 0000000000..53c4ee452b --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/record/RecordFieldValue.tsx @@ -0,0 +1,123 @@ +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { + IconBrandLinkedin, + IconBrandX, + IconCheck, + IconCurrencyDollar, + IconLink, + IconMapPin, + IconUser, +} from '@tabler/icons-react'; + +import { PersonAvatar } from '../../primitives/PersonAvatar'; +import { PreviewRoundedLink } from '../../primitives/PreviewRoundedLink'; +import { PreviewTag } from '../../primitives/PreviewTag'; +import { type RecordField } from '../../types'; + +const FieldValueSlot = styled.div` + align-items: center; + display: flex; + flex: 1 1 auto; + min-height: 24px; + min-width: 0; +`; + +const FieldValue = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const FieldValuePerson = styled.span` + align-items: center; + color: ${THEME_LIGHT.font.color.primary}; + display: flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + gap: 4px; + line-height: 1.4; + min-width: 0; +`; + +const FieldValuePersonName = styled.span` + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +function FieldIconGlyph({ iconName }: { iconName: string }) { + const stroke = THEME_LIGHT.icon.stroke.sm; + switch (iconName) { + case 'link': + return ; + case 'user': + return ; + case 'mapPin': + return ; + case 'check': + return ; + case 'currency': + return ; + case 'linkedin': + return ; + case 'twitter': + return ; + default: + return null; + } +} + +function FieldValueRenderer({ field }: { field: RecordField }) { + switch (field.value.type) { + case 'text': + return ( + + {field.value.value} + + ); + case 'boolean': + return ( + + {field.value.value ? 'True' : 'False'} + + ); + case 'currency': + return ( + + {field.value.value} + + ); + case 'link': + return ( + + + + ); + case 'person': + return ( + + + + {field.value.name} + + + ); + case 'select': + return ( + + + + ); + default: + return null; + } +} + +export const recordFieldValue = { FieldIconGlyph, FieldValueRenderer }; diff --git a/packages/twenty-website-redone/src/app-preview/pages/record/RecordFiles.tsx b/packages/twenty-website-redone/src/app-preview/pages/record/RecordFiles.tsx new file mode 100644 index 0000000000..687eb48a7a --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/record/RecordFiles.tsx @@ -0,0 +1,118 @@ +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { + IconCalendar, + IconDots, + IconFile, + IconFileText, + IconPlus, + IconTable, +} from '@tabler/icons-react'; + +import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones'; + +import { type RecordFile } from '../../types'; +import { RECORD_PANEL_CHROME } from './RecordPanelChrome'; + +const FILE_ICONS: Record< + RecordFile['category'], + { Icon: typeof IconFile; color: string } +> = { + pdf: { Icon: IconFileText, color: THEME_LIGHT.accent.accent9 }, + doc: { Icon: IconFileText, color: THEME_LIGHT.accent.accent9 }, + sheet: { Icon: IconTable, color: APP_PREVIEW_TONES.recordFileSheetInk }, + other: { Icon: IconFile, color: THEME_LIGHT.font.color.tertiary }, +}; + +const FileIconChip = styled.span<{ $color: string }>` + align-items: center; + background: ${({ $color }) => $color}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${THEME_LIGHT.font.color.inverted}; + display: flex; + flex-shrink: 0; + justify-content: center; + padding: 5px; +`; + +const FileName = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + flex: 1; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const FileDate = styled.span` + align-items: center; + color: ${THEME_LIGHT.font.color.light}; + display: flex; + flex-shrink: 0; + gap: 2px; + margin-left: auto; +`; + +const FileDateText = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + white-space: nowrap; +`; + +const FileDots = styled.span` + align-items: center; + color: ${THEME_LIGHT.font.color.tertiary}; + display: flex; + flex-shrink: 0; +`; + +const { + ActivityRowBox, + ListCard, + TabAddButton, + TabHeader, + TabHeaderCount, + TabHeaderLabel, + TabHeaderTitle, + TabSection, +} = RECORD_PANEL_CHROME; + +export function RecordFiles({ files }: { files: RecordFile[] }) { + return ( + + + + All + {files.length} + + + + Add file + + + + {files.map((file, index) => { + const { Icon, color } = FILE_ICONS[file.category]; + + return ( + + + + + {file.name} + + + {file.date} + + + + + + ); + })} + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/record/RecordNotes.tsx b/packages/twenty-website-redone/src/app-preview/pages/record/RecordNotes.tsx new file mode 100644 index 0000000000..1bf973bbcd --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/record/RecordNotes.tsx @@ -0,0 +1,205 @@ +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; + +import { EASING } from '@/tokens'; +import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones'; + +import { type RecordNote } from '../../types'; + +const NotesHeader = styled.div` + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 16px; + margin-top: 16px; + padding: 0 24px; +`; + +const NotesCount = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 600; +`; + +const AddNoteButton = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + line-height: 1.4; +`; + +const NotesGrid = styled.div` + display: grid; + gap: 16px; + grid-auto-rows: 1fr; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + overflow-y: auto; + padding: 0 24px 24px; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +const NoteCard = styled.div<{ + $highlighted?: boolean; + $index: number; + $muted?: boolean; +}>` + animation: noteCardAppear 360ms ${EASING.standard} both; + animation-delay: ${({ $index }) => `${120 + $index * 70}ms`}; + background: ${({ $highlighted }) => + $highlighted + ? THEME_LIGHT.background.primary + : THEME_LIGHT.background.secondary}; + border: 1px solid + ${({ $highlighted }) => + $highlighted + ? THEME_LIGHT.border.color.medium + : THEME_LIGHT.border.color.light}; + border-radius: ${THEME_LIGHT.border.radius.md}; + display: flex; + flex-direction: column; + height: 300px; + justify-content: space-between; + opacity: ${({ $muted }) => ($muted ? 0.56 : 1)}; + transform: ${({ $highlighted }) => + $highlighted ? 'translateY(-2px)' : 'none'}; + transition: + background 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease, + opacity 180ms ease, + transform 180ms ease; + box-shadow: ${({ $highlighted }) => + $highlighted ? APP_PREVIEW_TONES.recordNoteHighlightShadow : 'none'}; + + @keyframes noteCardAppear { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const NoteContent = styled.div` + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; + min-height: 0; + padding: 16px; +`; + +const NoteTitle = styled.div` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 14px; + font-weight: 500; + line-height: 1.35; +`; + +const NoteBody = styled.div` + color: ${THEME_LIGHT.font.color.secondary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; + white-space: pre-line; +`; + +const NoteRelation = styled.div` + align-items: center; + border-top: 1px solid ${THEME_LIGHT.border.color.light}; + display: flex; + gap: 6px; + justify-content: center; + min-height: 37px; + padding: 8px; +`; + +const NoteRelationArrow = styled.svg` + flex-shrink: 0; + height: 10px; + width: 10px; +`; + +const NoteRelationLabel = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 12px; + line-height: 1.4; +`; + +const NoteRelationName = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 12px; + line-height: 1.4; +`; + +const NoteRelationAvatar = styled.img` + border-radius: 50%; + height: 16px; + object-fit: cover; + width: 16px; +`; + +export function RecordNotes({ notes }: { notes: RecordNote[] }) { + const hasHighlightedNotes = notes.some((note) => note.highlighted); + + return ( + <> + + All {notes.length} + + Add note + + + + {notes.map((note, index) => ( + + + {note.title} + {note.body} + + {note.relation ? ( + + + + + Relations: + {note.relation.avatarUrl ? ( + + ) : null} + {note.relation.name} + + ) : null} + + ))} + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/record/RecordPage.tsx b/packages/twenty-website-redone/src/app-preview/pages/record/RecordPage.tsx new file mode 100644 index 0000000000..996d42653a --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/record/RecordPage.tsx @@ -0,0 +1,459 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { IconChevronDown } from '@tabler/icons-react'; +import { useEffect, useState } from 'react'; + +import { EASING } from '@/tokens'; + +import { FaviconLogo } from '../../primitives/FaviconLogo'; +import { type RecordPageDefinition } from '../../types'; +import { recordFieldValue } from './RecordFieldValue'; +import { RecordCalendar } from './RecordCalendar'; +import { RecordEmails } from './RecordEmails'; +import { RecordFiles } from './RecordFiles'; +import { RecordNotes } from './RecordNotes'; +import { RecordTasks } from './RecordTasks'; +import { RecordTimeline } from './RecordTimeline'; +import { recordTabs } from './record-tabs'; + +const Shell = styled.div` + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: hidden; + width: 100%; +`; + +const LeftPanel = styled.div` + border-right: 1px solid ${THEME_LIGHT.border.color.light}; + display: flex; + flex: 0 0 248px; + flex-direction: column; + gap: 16px; + overflow-y: auto; + padding: 16px; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +const RecordHeader = styled.div` + align-items: center; + animation: recordHeaderAppear 420ms ${EASING.standard} both; + animation-delay: 120ms; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 127px; + justify-content: center; + padding-bottom: 8px; + + @keyframes recordHeaderAppear { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const RecordName = styled.div` + font-family: ${THEME_LIGHT.font.family}; + font-size: 20px; + font-weight: 600; + color: ${THEME_LIGHT.font.color.primary}; + line-height: 1.3; + text-align: center; +`; + +const RecordMeta = styled.div` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + line-height: 1.4; +`; + +const FieldList = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +`; + +const FieldRow = styled.div<{ $index: number }>` + align-items: flex-start; + animation: fieldRowAppear 420ms ${EASING.standard} both; + animation-delay: ${({ $index }) => `${190 + $index * 70}ms`}; + display: flex; + gap: 8px; + min-height: 24px; + + @keyframes fieldRowAppear { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const FieldMeta = styled.div` + align-items: center; + color: ${THEME_LIGHT.font.color.tertiary}; + display: flex; + flex: 0 0 110px; + gap: 4px; + min-height: 24px; + min-width: 0; +`; + +const FieldIcon = styled.span` + align-items: center; + color: ${THEME_LIGHT.font.color.tertiary}; + display: flex; + flex: 0 0 16px; + height: 16px; + justify-content: center; + width: 16px; + + svg { + display: block; + height: 16px; + width: 16px; + } +`; + +const FieldLabel = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 90px; +`; + +const MoreToggle = styled.div` + align-items: center; + color: ${THEME_LIGHT.font.color.tertiary}; + cursor: default; + display: flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: 12px; + gap: 4px; + line-height: 1.4; + padding: 4px 0; +`; + +const Divider = styled.div` + border-top: 1px solid ${THEME_LIGHT.border.color.light}; + margin: 4px 0; +`; + +const RelationSection = styled.div` + animation: relationAppear 420ms ${EASING.standard} both; + animation-delay: 600ms; + display: flex; + flex-direction: column; + gap: 6px; + + @keyframes relationAppear { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const RelationTitle = styled.div` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 12px; + font-weight: 500; + line-height: 1.4; +`; + +const RelationTitleCount = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-weight: 400; + margin-left: 4px; +`; + +const RelationItem = styled.div<{ $highlighted?: boolean; $muted?: boolean }>` + align-items: center; + background: ${({ $highlighted }) => + $highlighted ? THEME_LIGHT.background.secondary : 'transparent'}; + border: 1px solid + ${({ $highlighted }) => + $highlighted ? THEME_LIGHT.border.color.medium : 'transparent'}; + border-radius: 6px; + display: flex; + gap: 6px; + opacity: ${({ $muted }) => ($muted ? 0.55 : 1)}; + padding: 2px 0; + padding-inline: ${({ $highlighted }) => ($highlighted ? '6px' : '0')}; + transition: + background 180ms ease, + border-color 180ms ease, + opacity 180ms ease; +`; + +const RelationName = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 12px; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const RelationAvatarImage = styled.img` + border-radius: 50%; + flex: 0 0 auto; + height: 16px; + object-fit: cover; + width: 16px; +`; + +const CenterPanel = styled.div` + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + overflow: hidden; +`; + +const TabBar = styled.div` + align-items: center; + animation: tabBarAppear 260ms ease-out both; + animation-delay: 120ms; + border-bottom: 1px solid ${THEME_LIGHT.border.color.light}; + display: flex; + flex: 0 0 auto; + gap: 4px; + min-height: 40px; + padding: 0 8px; + + @keyframes tabBarAppear { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const Tab = styled.div<{ $active?: boolean; $clickable?: boolean }>` + all: unset; + align-items: center; + color: ${({ $active }) => + $active + ? THEME_LIGHT.font.color.primary + : THEME_LIGHT.font.color.secondary}; + cursor: ${({ $clickable }) => ($clickable ? 'pointer' : 'default')}; + display: flex; + position: relative; + text-decoration: none; + white-space: nowrap; + + &::after { + background-color: ${({ $active }) => + $active ? THEME_LIGHT.font.color.primary : 'transparent'}; + bottom: 0; + content: ''; + height: 1px; + left: 0; + position: absolute; + right: 0; + z-index: 1; + } +`; + +const TabInner = styled.span` + display: flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 500; + gap: 4px; + line-height: 1.4; + padding: 4px 8px; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: inherit; +`; + +const { FieldIconGlyph, FieldValueRenderer } = recordFieldValue; + +export function RecordPage({ page }: { page: RecordPageDefinition }) { + const { record, notes, timeline, tasks, files, emails, calendar } = page; + const availableTabs = recordTabs.getAvailable(page); + const controlledTabLabel = page.activeTabLabel; + const isControlled = controlledTabLabel !== undefined; + const [activeTabIndex, setActiveTabIndex] = useState(0); + const [isInteractive, setIsInteractive] = useState(false); + + useEffect(() => { + if (isControlled || isInteractive) { + return undefined; + } + + if (activeTabIndex >= availableTabs.length - 1) { + setIsInteractive(true); + return undefined; + } + + const timer = setTimeout(() => { + setActiveTabIndex((current) => current + 1); + }, recordTabs.DWELL_MS); + + return () => clearTimeout(timer); + }, [availableTabs.length, activeTabIndex, isControlled, isInteractive]); + + const activeTabLabel = + controlledTabLabel ?? availableTabs[activeTabIndex]?.label ?? 'Notes'; + + const handleTabClick = (label: string) => { + if (!isInteractive) { + return; + } + + const nextIndex = availableTabs.findIndex((tab) => tab.label === label); + + if (nextIndex >= 0) { + setActiveTabIndex(nextIndex); + } + }; + const hasHighlightedRelations = record.relations.some((section) => + section.items.some((item) => item.highlighted), + ); + + return ( + + + + + {record.name} + {record.createdAt} + + + + {record.fields.map((field, index) => ( + + + + {field.icon ? : null} + + {field.label} + + + + ))} + + + {record.moreCount ? ( + + + More ({record.moreCount}) + + ) : null} + + + + {record.relations.map((section) => ( + + + {section.title} + {section.count ? ( + All ({section.count}) + ) : null} + + {section.items.map((item) => ( + + {item.avatarUrl ? ( + + ) : ( + + )} + {item.name} + + ))} + + ))} + + + + + {recordTabs.LIST.map((tab) => { + const isActive = tab.label === activeTabLabel; + + return ( + handleTabClick(tab.label)} + > + + + {tab.label} + + + ); + })} + + + {activeTabLabel === 'Timeline' && timeline ? ( + + ) : null} + {activeTabLabel === 'Tasks' && tasks ? ( + + ) : null} + {activeTabLabel === 'Notes' ? : null} + {activeTabLabel === 'Files' && files ? ( + + ) : null} + {activeTabLabel === 'Emails' && emails ? ( + + ) : null} + {activeTabLabel === 'Calendar' && calendar ? ( + + ) : null} + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/record/RecordPanelChrome.tsx b/packages/twenty-website-redone/src/app-preview/pages/record/RecordPanelChrome.tsx new file mode 100644 index 0000000000..388b7648bc --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/record/RecordPanelChrome.tsx @@ -0,0 +1,102 @@ +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; + +import { EASING } from '@/tokens'; + +const TabSection = styled.div` + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 0 24px 24px; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +const TabHeader = styled.div` + align-items: center; + display: flex; + justify-content: space-between; + margin: 16px 0; +`; + +const TabHeaderLabel = styled.span` + align-items: baseline; + display: inline-flex; +`; + +const TabHeaderTitle = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 600; +`; + +const TabHeaderCount = styled.span` + color: ${THEME_LIGHT.font.color.light}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + margin-left: 8px; +`; + +const TabAddButton = styled.span` + align-items: center; + border: 1px solid ${THEME_LIGHT.border.color.medium}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${THEME_LIGHT.font.color.secondary}; + display: inline-flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + gap: 4px; + height: 26px; + padding: 0 8px; +`; + +const ListCard = styled.div` + background: ${THEME_LIGHT.background.secondary}; + border: 1px solid ${THEME_LIGHT.border.color.medium}; + border-radius: ${THEME_LIGHT.border.radius.md}; + overflow: hidden; +`; + +// Mirrors twenty-front's ActivityRow: 48px tall, 16px horizontal padding, +// 8px gap, sitting inside a bordered card with 1px dividers between rows. +const ActivityRowBox = styled.div<{ $index: number }>` + align-items: center; + animation: activityRowAppear 360ms ${EASING.standard} both; + animation-delay: ${({ $index }) => `${120 + $index * 70}ms`}; + display: flex; + gap: 8px; + height: 48px; + padding: 0 16px; + + & + & { + border-top: 1px solid ${THEME_LIGHT.border.color.light}; + } + + @keyframes activityRowAppear { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +// The activity-tab chrome every list panel (tasks, files, emails, +// calendar) composes: section scroller, counted header, bordered card. +export const RECORD_PANEL_CHROME = { + ActivityRowBox, + ListCard, + TabAddButton, + TabHeader, + TabHeaderCount, + TabHeaderLabel, + TabHeaderTitle, + TabSection, +}; diff --git a/packages/twenty-website-redone/src/app-preview/pages/record/RecordTasks.tsx b/packages/twenty-website-redone/src/app-preview/pages/record/RecordTasks.tsx new file mode 100644 index 0000000000..ae3aa14842 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/record/RecordTasks.tsx @@ -0,0 +1,153 @@ +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { IconCalendar, IconCheck, IconPlus } from '@tabler/icons-react'; + +import { FaviconLogo } from '../../primitives/FaviconLogo'; +import { PersonAvatar } from '../../primitives/PersonAvatar'; +import { type RecordTask } from '../../types'; +import { RECORD_PANEL_CHROME } from './RecordPanelChrome'; + +const TaskLeft = styled.div` + align-items: center; + display: flex; + flex: 1; + overflow: hidden; +`; + +const TaskCheckbox = styled.span<{ $done?: boolean }>` + align-items: center; + background: ${({ $done }) => + $done ? THEME_LIGHT.accent.accent9 : 'transparent'}; + border: 1px solid + ${({ $done }) => + $done ? THEME_LIGHT.accent.accent9 : THEME_LIGHT.font.color.primary}; + border-radius: 50%; + color: ${THEME_LIGHT.font.color.inverted}; + display: flex; + flex-shrink: 0; + height: 16px; + justify-content: center; + width: 16px; +`; + +const TaskTitle = styled.span<{ $done?: boolean }>` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 500; + overflow: hidden; + padding: 0 8px; + text-decoration: ${({ $done }) => ($done ? 'line-through' : 'none')}; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const TaskBody = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const TaskRight = styled.div` + align-items: center; + display: inline-flex; + flex-shrink: 0; + gap: 8px; + margin-left: auto; +`; + +const DueDate = styled.span` + align-items: center; + color: ${THEME_LIGHT.font.color.secondary}; + display: flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + gap: 4px; + white-space: nowrap; +`; + +const TargetChip = styled.span` + align-items: center; + background: ${THEME_LIGHT.background.secondary}; + border: 1px solid ${THEME_LIGHT.border.color.light}; + border-radius: 50px; + color: ${THEME_LIGHT.font.color.primary}; + display: inline-flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + gap: 4px; + max-width: 160px; + padding: 1px 8px 1px 2px; + white-space: nowrap; +`; + +const TargetChipName = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const { + ActivityRowBox, + ListCard, + TabAddButton, + TabHeader, + TabHeaderCount, + TabHeaderLabel, + TabHeaderTitle, + TabSection, +} = RECORD_PANEL_CHROME; + +export function RecordTasks({ tasks }: { tasks: RecordTask[] }) { + return ( + + + + To do + {tasks.length} + + + + Add task + + + + {tasks.map((task, index) => ( + + + + {task.done ? : null} + + {task.title} + {task.body} + + + + + {task.due} + + + {task.target.domain ? ( + + ) : ( + + )} + {task.target.name} + + + + ))} + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/record/RecordTimeline.tsx b/packages/twenty-website-redone/src/app-preview/pages/record/RecordTimeline.tsx new file mode 100644 index 0000000000..9101745483 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/record/RecordTimeline.tsx @@ -0,0 +1,418 @@ +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { + IconCalendarEvent, + IconChevronUp, + IconCirclePlus, + IconEditCircle, + IconNotes, +} from '@tabler/icons-react'; + +import { EASING } from '@/tokens'; + +import { PersonAvatar } from '../../primitives/PersonAvatar'; +import { PreviewTag } from '../../primitives/PreviewTag'; +import { type RecordFieldValue, type TimelineEvent } from '../../types'; + +const TimelineFeed = styled.div` + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 16px 24px 24px; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +const MonthSeparator = styled.div` + align-items: center; + color: ${THEME_LIGHT.font.color.light}; + display: flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: 12px; + font-weight: 600; + gap: 16px; + margin-bottom: 16px; +`; + +const MonthSeparatorLine = styled.div` + background: ${THEME_LIGHT.border.color.light}; + border-radius: 50px; + flex: 1; + height: 1px; +`; + +const TimelineGroup = styled.div` + position: relative; +`; + +// The 24px rounded rail twenty-front renders behind the event icons. +const TimelineRail = styled.div` + background: ${THEME_LIGHT.background.secondary}; + border: 1px solid ${THEME_LIGHT.border.color.light}; + border-radius: ${THEME_LIGHT.border.radius.md}; + bottom: 0; + left: 0; + position: absolute; + top: 0; + width: 24px; + z-index: 0; +`; + +const TimelineRow = styled.div<{ $index: number }>` + animation: timelineRowAppear 360ms ${EASING.standard} both; + animation-delay: ${({ $index }) => `${120 + $index * 70}ms`}; + display: flex; + gap: 12px; + position: relative; + z-index: 1; + + @keyframes timelineRowAppear { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const TimelineGutter = styled.div` + align-items: center; + display: flex; + flex-direction: column; + flex-shrink: 0; + width: 24px; +`; + +const TimelineIconBox = styled.div` + align-items: center; + color: ${THEME_LIGHT.font.color.tertiary}; + display: flex; + flex-shrink: 0; + height: 16px; + justify-content: center; + width: 16px; +`; + +const TimelineConnector = styled.div<{ $hidden: boolean }>` + background: ${THEME_LIGHT.border.color.light}; + flex: 1; + margin: 4px 0; + min-height: 12px; + opacity: ${({ $hidden }) => ($hidden ? 0 : 1)}; + width: 2px; +`; + +const TimelineMain = styled.div` + display: flex; + flex: 1; + flex-direction: column; + gap: 6px; + min-width: 0; + padding-bottom: 16px; +`; + +const TimelineSummary = styled.div` + align-items: center; + display: flex; + gap: 4px; + justify-content: space-between; + min-height: 24px; +`; + +const TimelineSummaryLeft = styled.div` + align-items: center; + display: flex; + gap: 4px; + min-width: 0; + overflow: hidden; +`; + +const TimelineActor = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 500; + white-space: nowrap; +`; + +const TimelineAction = styled.span` + color: ${THEME_LIGHT.font.color.secondary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + white-space: nowrap; +`; + +const TimelineSubject = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 500; + white-space: nowrap; +`; + +const TimelineDiffLabel = styled.span` + color: ${THEME_LIGHT.font.color.secondary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + white-space: nowrap; +`; + +const TimelineArrow = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; +`; + +const TimelineValueText = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + white-space: nowrap; +`; + +const TimelineDiffPerson = styled.span` + align-items: center; + display: inline-flex; + gap: 4px; +`; + +const TimelineLinkedTitle = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + overflow: hidden; + text-decoration: underline; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const TimelineTime = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + flex-shrink: 0; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + padding-left: 8px; + white-space: nowrap; +`; + +const TimelineToggle = styled.span` + align-items: center; + border: 1px solid ${THEME_LIGHT.border.color.light}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${THEME_LIGHT.font.color.tertiary}; + display: inline-flex; + flex-shrink: 0; + height: 20px; + justify-content: center; + width: 20px; +`; + +const TimelineCardOuter = styled.div` + max-width: 360px; + padding-top: 2px; + width: 100%; +`; + +const TimelineCardInner = styled.div` + background: ${THEME_LIGHT.background.secondary}; + border: 1px solid ${THEME_LIGHT.border.color.medium}; + border-radius: ${THEME_LIGHT.border.radius.md}; + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; +`; + +const TimelineDiffRow = styled.div` + align-items: center; + display: flex; + gap: 4px; + min-height: 24px; +`; + +const TimelineCardTitle = styled.div` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 500; +`; + +const TimelineCardText = styled.div` + color: ${THEME_LIGHT.font.color.secondary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + line-height: 1.4; +`; + +function TimelineEventIcon({ kind }: { kind: TimelineEvent['kind'] }) { + const stroke = THEME_LIGHT.icon.stroke.sm; + switch (kind) { + case 'created': + return ; + case 'updated': + return ; + case 'note': + return ; + case 'calendar': + return ; + default: + return null; + } +} + +function TimelineDiffValue({ value }: { value: RecordFieldValue }) { + switch (value.type) { + case 'select': + return ; + case 'person': + return ( + + + {value.name} + + ); + case 'boolean': + return ( + {value.value ? 'True' : 'False'} + ); + case 'currency': + case 'text': + return {value.value}; + case 'link': + return ( + {value.label ?? value.value} + ); + default: + return null; + } +} + +function TimelineEventSummary({ event }: { event: TimelineEvent }) { + const stroke = THEME_LIGHT.icon.stroke.sm; + switch (event.kind) { + case 'created': + return ( + <> + {event.subject} + was created by + {event.actor} + + ); + case 'note': + return ( + <> + {event.actor} + created a note + {event.title} + + ); + case 'calendar': + return ( + <> + {event.actor} + added a calendar event + {event.title} + + + + + ); + case 'updated': + if (event.diffs.length === 1) { + return ( + <> + {event.actor} + updated + {event.diffs[0].label} + + + + ); + } + + return ( + <> + {event.actor} + updated + + {event.diffs.length} fields on {event.record} + + + + + + ); + default: + return null; + } +} + +function TimelineEventCard({ event }: { event: TimelineEvent }) { + if (event.kind === 'updated' && event.diffs.length > 1) { + return ( + + + {event.diffs.map((diff) => ( + + {diff.label} + + + + ))} + + + ); + } + + if (event.kind === 'calendar') { + return ( + + + {event.title} + {event.detail} + + + ); + } + + return null; +} + +export function RecordTimeline({ timeline }: { timeline: TimelineEvent[] }) { + return ( + + + Today + + + + + {timeline.map((event, index) => ( + + + + + + + + + + + + + {event.time} + + + + + ))} + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/record/record-tabs.test.ts b/packages/twenty-website-redone/src/app-preview/pages/record/record-tabs.test.ts new file mode 100644 index 0000000000..ce837748bf --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/record/record-tabs.test.ts @@ -0,0 +1,106 @@ +import { recordTabs } from './record-tabs'; +import { type RecordPageDefinition } from '../../types'; + +const basePage: RecordPageDefinition = { + type: 'record', + header: { title: 'Anthropic' }, + record: { + name: 'Anthropic', + createdAt: 'Added 3 months ago', + fields: [], + relations: [], + }, + notes: [], +}; + +const note = { id: 'note-1', title: 'Kickoff', body: 'Notes body' }; + +describe('recordTabs.getAvailable', () => { + it('should expose only tabs whose panels have content', () => { + const page: RecordPageDefinition = { + ...basePage, + notes: [note], + tasks: [ + { + id: 'task-1', + title: 'Follow up', + body: 'Send recap', + due: 'Tomorrow', + target: { name: 'Anthropic', domain: 'anthropic.com' }, + }, + ], + }; + + expect(recordTabs.getAvailable(page).map((tab) => tab.label)).toEqual([ + 'Tasks', + 'Notes', + ]); + }); + + it('should keep the authored tab order when everything is populated', () => { + const page: RecordPageDefinition = { + ...basePage, + notes: [note], + timeline: [ + { + kind: 'created', + id: 'event-1', + subject: 'Anthropic', + actor: 'Lucie', + time: '2:30 PM', + }, + ], + tasks: [ + { + id: 'task-1', + title: 'Follow up', + body: 'Send recap', + due: 'Tomorrow', + target: { name: 'Anthropic' }, + }, + ], + files: [ + { id: 'file-1', name: 'MSA.pdf', category: 'pdf', date: '2 Jun' }, + ], + emails: [ + { + id: 'email-1', + participants: [{ name: 'Dario' }], + count: 3, + subject: 'Renewal', + body: 'Thanks for the call', + date: '2 Jun', + }, + ], + calendar: [ + { + id: 'day-1', + weekday: 'Mon', + day: '9', + events: [ + { + id: 'cal-1', + start: '9:00', + end: '9:30', + title: 'Sync', + participants: [{ name: 'Dario' }], + }, + ], + }, + ], + }; + + expect(recordTabs.getAvailable(page).map((tab) => tab.label)).toEqual([ + 'Timeline', + 'Tasks', + 'Notes', + 'Files', + 'Emails', + 'Calendar', + ]); + }); + + it('should return no tabs for an empty record', () => { + expect(recordTabs.getAvailable(basePage)).toEqual([]); + }); +}); diff --git a/packages/twenty-website-redone/src/app-preview/pages/record/record-tabs.ts b/packages/twenty-website-redone/src/app-preview/pages/record/record-tabs.ts new file mode 100644 index 0000000000..40a160228a --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/record/record-tabs.ts @@ -0,0 +1,49 @@ +import { + IconCalendarEvent, + IconCheckbox, + IconMail, + IconNotes, + IconPaperclip, + IconTimelineEvent, +} from '@tabler/icons-react'; + +import { type RecordPageDefinition } from '../../types'; + +export type RecordTabLabel = + | 'Timeline' + | 'Tasks' + | 'Notes' + | 'Files' + | 'Emails' + | 'Calendar'; + +type RecordTab = { label: RecordTabLabel; Icon: typeof IconNotes }; + +const LIST: readonly RecordTab[] = [ + { label: 'Timeline', Icon: IconTimelineEvent }, + { label: 'Tasks', Icon: IconCheckbox }, + { label: 'Notes', Icon: IconNotes }, + { label: 'Files', Icon: IconPaperclip }, + { label: 'Emails', Icon: IconMail }, + { label: 'Calendar', Icon: IconCalendarEvent }, +]; + +const getAvailable = (page: RecordPageDefinition): readonly RecordTab[] => { + const hasContent: Record = { + Timeline: (page.timeline?.length ?? 0) > 0, + Tasks: (page.tasks?.length ?? 0) > 0, + Notes: page.notes.length > 0, + Files: (page.files?.length ?? 0) > 0, + Emails: (page.emails?.length ?? 0) > 0, + Calendar: (page.calendar?.length ?? 0) > 0, + }; + return LIST.filter((tab) => hasContent[tab.label]); +}; + +// The tab strip auto-advances through every populated tab once (2400ms +// dwell per tab), then unlocks for the visitor's own clicks. +export const recordTabs = { + DWELL_MS: 2400, + LIST, + getAvailable, +}; diff --git a/packages/twenty-website-redone/src/app-preview/pages/table/TableCellValue.tsx b/packages/twenty-website-redone/src/app-preview/pages/table/TableCellValue.tsx new file mode 100644 index 0000000000..33d1ffda5c --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/table/TableCellValue.tsx @@ -0,0 +1,335 @@ +import { styled } from '@linaria/react'; +import { IconCheck, IconCopy, IconPencil, IconX } from '@tabler/icons-react'; +import { type ReactNode } from 'react'; + +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { previewFontSize } from '@/app-preview/preview-font-size'; + +import { TableCheckbox } from './TableCheckbox'; +import { Chip, type ChipVariant } from '../../primitives/Chip'; +import { FaviconLogo } from '../../primitives/FaviconLogo'; +import { getInitials } from '../../primitives/get-initials'; +import { MiniIcon } from '../../primitives/MiniIcon'; +import { PersonAvatar } from '../../primitives/PersonAvatar'; +import { PreviewAvatar } from '../../primitives/PreviewAvatar'; +import { PreviewRoundedLink } from '../../primitives/PreviewRoundedLink'; +import { PreviewTag } from '../../primitives/PreviewTag'; +import { + type CellEntity, + type CellLink, + type CellPerson, + type CellRelation, + type CellText, + type CellValue, +} from '../../types'; + +// The floating row action sits over the cell's right padding. +const CELL_HORIZONTAL_PADDING = 8; +const HOVER_ACTION_EDGE_INSET = 4; +const ROW_HOVER_ACTION_DISABLED_COLUMNS = new Set([ + 'createdBy', + 'accountOwner', +]); + +const FirstColumnCellLayout = styled.div` + align-items: center; + display: flex; + gap: 4px; + height: 100%; + min-width: 0; + position: relative; + width: 100%; +`; + +const CellHoverAnchor = styled.div` + align-items: center; + display: flex; + height: 100%; + min-width: 0; + position: relative; + width: 100%; +`; + +const InlineText = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.regular}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const RightAlignedText = styled(InlineText)` + text-align: right; + width: 100%; +`; + +const BooleanRow = styled.div` + align-items: center; + display: inline-flex; + gap: 4px; +`; + +const HoverActions = styled.div<{ $visible: boolean }>` + align-items: center; + background: ${THEME_LIGHT.background.transparent.primary}; + border: 1px solid ${THEME_LIGHT.background.transparent.light}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + bottom: 4px; + box-sizing: border-box; + box-shadow: ${THEME_LIGHT.boxShadow.light}; + display: flex; + gap: 0; + justify-content: center; + opacity: ${({ $visible }) => ($visible ? 1 : 0)}; + padding: 0 4px; + pointer-events: none; + position: absolute; + right: ${HOVER_ACTION_EDGE_INSET - CELL_HORIZONTAL_PADDING}px; + top: 4px; + transform: translateX(${({ $visible }) => ($visible ? '0' : '4px')}); + transition: + opacity 0.14s ease, + transform 0.14s ease; + width: 24px; +`; + +const MiniAction = styled.div` + align-items: center; + border-radius: ${THEME_LIGHT.border.radius.xs}; + color: ${THEME_LIGHT.font.color.secondary}; + display: flex; + height: 16px; + justify-content: center; + width: 16px; +`; + +const MultiChipStack = styled.div` + align-items: center; + display: flex; + gap: 4px; + min-width: 0; + overflow: hidden; + width: 100%; +`; + +function HoverAction({ + icon, + visible, +}: { + icon: typeof IconCopy; + visible: boolean; +}) { + return ( + + + + + + ); +} + +function PersonTokenCell({ + isFirstColumn = false, + token, + hovered = false, + variant = 'highlighted', +}: { + hovered?: boolean; + isFirstColumn?: boolean; + token: CellPerson; + variant?: ChipVariant; +}) { + const content = ( + } + variant={variant} + /> + ); + if (isFirstColumn) { + return ( + + + {content} + + + ); + } + return ( + + {content} + + + ); +} + +function EntityCellComponent({ + cell, + hovered, + isFirstColumn, +}: { + cell: CellEntity; + hovered: boolean; + isFirstColumn: boolean; +}) { + const content = ( + } + variant="highlighted" + /> + ); + if (isFirstColumn) { + return ( + + + {content} + + + ); + } + return ( + + {content} + + + ); +} + +function RelationCellComponent({ cell }: { cell: CellRelation }) { + return ( + + + {cell.items.map((item) => ( + + {item.shortLabel ?? getInitials(item.name)} + + } + variant="highlighted" + /> + ))} + + + ); +} + +function TextCellComponent({ + cell, + isFirstColumn, +}: { + cell: CellText; + isFirstColumn: boolean; +}) { + if (!isFirstColumn) { + return {cell.value}; + } + const content = cell.shortLabel ? ( + {cell.shortLabel} + } + variant="highlighted" + /> + ) : ( + + ); + return ( + + + {content} + + ); +} + +function LinkCellComponent({ cell }: { cell: CellLink }) { + const label = + cell.label ?? + (cell.kind === 'social' && cell.value.startsWith('@') + ? cell.value + : cell.value); + return ( + + + + ); +} + +export function renderTableCellValue({ + cell, + columnId, + hovered, + isFirstColumn, +}: { + cell: CellValue; + columnId: string; + hovered: boolean; + isFirstColumn: boolean; +}): ReactNode { + const showHoverAction = !ROW_HOVER_ACTION_DISABLED_COLUMNS.has(columnId); + const personChipVariant: ChipVariant = + columnId === 'createdBy' ? 'transparent' : 'highlighted'; + + switch (cell.type) { + case 'text': + return ; + case 'number': + return {cell.value}; + case 'currency': + return {cell.value}; + case 'link': + return ; + case 'boolean': + return ( + + {cell.value ? ( + + ) : ( + + )} + {cell.value ? 'True' : 'False'} + + ); + case 'select': + return ; + case 'person': + return ( + + ); + case 'entity': + return ( + + ); + case 'relation': + return ; + } +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/table/TableCheckbox.tsx b/packages/twenty-website-redone/src/app-preview/pages/table/TableCheckbox.tsx new file mode 100644 index 0000000000..2f5c6b5eee --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/table/TableCheckbox.tsx @@ -0,0 +1,43 @@ +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { type ReactNode } from 'react'; + +const CheckboxContainer = styled.div` + align-items: center; + display: flex; + flex: 0 0 24px; + height: 24px; + justify-content: center; + width: 24px; +`; + +const CheckboxBox = styled.div<{ $checked?: boolean }>` + align-items: center; + background: ${({ $checked }) => + $checked ? THEME_LIGHT.background.transparent.blue : 'transparent'}; + border: 1px solid + ${({ $checked }) => + $checked + ? THEME_LIGHT.border.color.blue + : THEME_LIGHT.font.color.primary}; + border-radius: 3px; + display: flex; + flex: 0 0 auto; + height: 14px; + justify-content: center; + width: 14px; +`; + +export function TableCheckbox({ + checked, + children, +}: { + checked?: boolean; + children?: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/table/TableHeaderIcon.tsx b/packages/twenty-website-redone/src/app-preview/pages/table/TableHeaderIcon.tsx new file mode 100644 index 0000000000..dbe4b5ef36 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/table/TableHeaderIcon.tsx @@ -0,0 +1,44 @@ +import { + IconBrandLinkedin, + IconBuildingFactory2, + IconCalendarEvent, + IconCreativeCommonsSa, + IconLink, + IconMap2, + IconMoneybag, + IconTarget, + IconTargetArrow, + IconUser, + IconUserCircle, + IconUsers, +} from '@tabler/icons-react'; +import { type ReactNode } from 'react'; + +import { THEME_LIGHT } from 'twenty-ui/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-redone/src/app-preview/pages/table/TablePage.tsx b/packages/twenty-website-redone/src/app-preview/pages/table/TablePage.tsx new file mode 100644 index 0000000000..2e3a61003e --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/table/TablePage.tsx @@ -0,0 +1,388 @@ +'use client'; + +import { styled } from '@linaria/react'; + +import { EASING } from '@/tokens'; +import { IconChevronDown, IconPlus } from '@tabler/icons-react'; +import { useState } from 'react'; + +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { previewFontSize } from '@/app-preview/preview-font-size'; +import { APP_PREVIEW_CHROME } from '@/app-preview/app-preview-chrome'; + +import { renderTableCellValue } from './TableCellValue'; +import { TableCheckbox } from './TableCheckbox'; +import { renderTableHeaderIcon } from './TableHeaderIcon'; +import { MiniIcon } from '../../primitives/MiniIcon'; +import { PREVIEW_SKELETON } from '../../primitives/PreviewSkeleton'; +import { useHorizontalDragScroll } from '@/platform/motion'; +import { type TablePageDefinition } from '../../types'; + +const CELL_HORIZONTAL_PADDING = 8; + +const TableShell = styled.div` + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: hidden; + width: 100%; +`; + +const TableViewport = styled.div<{ $dragging: boolean }>` + cursor: ${({ $dragging }) => ($dragging ? 'grabbing' : 'grab')}; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scrollbar-width: none; + width: 100%; + + &::-webkit-scrollbar { + display: none; + } +`; + +const TableCanvas = styled.div<{ $width: number }>` + display: flex; + flex-direction: column; + height: 100%; + min-height: 100%; + min-width: ${({ $width }) => `${$width}px`}; + width: ${({ $width }) => `${$width}px`}; +`; + +const HeaderRow = styled.div` + animation: tableHeaderAppear 260ms ease-out both; + display: flex; + + @keyframes tableHeaderAppear { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const DataRow = styled.div<{ $rowIndex: number }>` + animation: tableRowAppear 420ms ${EASING.standard} both; + animation-delay: ${({ $rowIndex }) => `${120 + $rowIndex * 70}ms`}; + display: flex; + max-height: 0; + /* Axis-split clip: the reveal animation clips VERTICALLY only. Unlike + overflow:hidden, clip never creates a scroll container, so the + sticky first column holds at every instant — including mid-reveal. + The product pins this column (twenty-front's record table); the old + mockup ships it broken (user-ratified improvement). */ + overflow-x: visible; + overflow-y: clip; + + @keyframes tableRowAppear { + from { + opacity: 0; + max-height: 0; + } + to { + opacity: 1; + max-height: ${APP_PREVIEW_CHROME.recordTableRowHeightPx}px; + } + } +`; + +const FooterRow = styled.div` + display: flex; +`; + +const TableCell = styled.div<{ + $align?: 'left' | 'right'; + $header?: boolean; + $hovered?: boolean; + $sticky?: boolean; + $width: number; +}>` + align-items: center; + background: ${({ $header, $hovered }) => { + if ($header) { + return THEME_LIGHT.background.primary; + } + return $hovered + ? THEME_LIGHT.background.secondary + : THEME_LIGHT.background.primary; + }}; + border-bottom: 1px solid ${THEME_LIGHT.border.color.light}; + border-right: 1px solid ${THEME_LIGHT.border.color.light}; + box-sizing: border-box; + display: flex; + flex: 0 0 ${({ $width }) => `${$width}px`}; + height: ${APP_PREVIEW_CHROME.recordTableRowHeightPx}px; + justify-content: ${({ $align }) => + $align === 'right' ? 'flex-end' : 'flex-start'}; + left: ${({ $sticky }) => ($sticky ? '0' : 'auto')}; + min-width: ${({ $width }) => `${$width}px`}; + padding-bottom: 0; + padding-right: ${CELL_HORIZONTAL_PADDING}px; + padding-top: 0; + padding-left: ${({ $sticky }) => + $sticky + ? `${CELL_HORIZONTAL_PADDING - 1}px` + : `${CELL_HORIZONTAL_PADDING}px`}; + position: ${({ $sticky }) => ($sticky ? 'sticky' : 'relative')}; + z-index: ${({ $header, $sticky }) => { + if ($sticky && $header) { + return 6; + } + if ($sticky) { + return 4; + } + return 1; + }}; +`; + +const EmptyFillCell = styled.div<{ + $footer?: boolean; + $header?: boolean; + $hovered?: boolean; + $width: number; +}>` + background: ${({ $header, $hovered, $footer }) => { + if ($header || $footer) { + return THEME_LIGHT.background.primary; + } + return $hovered + ? THEME_LIGHT.background.secondary + : THEME_LIGHT.background.primary; + }}; + border-bottom: 1px solid ${THEME_LIGHT.border.color.light}; + flex: 0 0 ${({ $width }) => `${$width}px`}; + min-width: ${({ $width }) => `${$width}px`}; +`; + +const HeaderCellContent = styled.div` + align-items: center; + display: flex; + gap: 4px; + height: 100%; + min-width: 0; + width: 100%; +`; + +const HeaderLabel = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.medium}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const MutedText = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.regular}; + line-height: 1.4; + white-space: nowrap; +`; + +const FooterFirstContent = styled.div` + align-items: center; + display: inline-flex; + gap: 4px; + padding-left: 28px; +`; + +const HeaderFillContent = styled.div` + align-items: center; + display: flex; + height: 100%; + padding: 0 8px; +`; + +const EdgePlus = styled.div` + margin-left: auto; +`; + +const SKELETON_ROW_INDEXES = [0, 1, 2, 3, 4, 5, 6]; + +const SkeletonRowLead = styled.div` + align-items: center; + display: flex; + gap: 6px; + width: 100%; +`; + +export function TablePage({ page }: { page: TablePageDefinition }) { + const { + dragging, + onPointerCancel, + onPointerDown, + onPointerLeave, + onPointerMove, + onPointerUp, + viewportRef, + } = useHorizontalDragScroll(); + const [hoveredRowId, setHoveredRowId] = useState(null); + + const columnWidth = page.columns.reduce( + (total, column) => total + column.width, + 0, + ); + const totalTableWidth = page.width ?? columnWidth; + const fillerWidth = Math.max(totalTableWidth - columnWidth, 0); + + return ( + + + + + {page.columns.map((column) => ( + + + {column.isFirstColumn ? ( + <> + + {renderTableHeaderIcon(column.id)} + {column.label} + + + + + ) : ( + <> + {renderTableHeaderIcon(column.id)} + {column.label} + + )} + + + ))} + + {fillerWidth > 0 ? ( + + + + ) : null} + + + {page.generating + ? SKELETON_ROW_INDEXES.map((rowIndex) => ( + + {page.columns.map((column) => ( + + {column.isFirstColumn ? ( + + + + + ) : ( + + )} + + ))} + + + )) + : page.rows.map((row, rowIndex) => { + const hovered = hoveredRowId === row.id; + return ( + setHoveredRowId(row.id)} + onMouseLeave={() => + setHoveredRowId((current) => + current === row.id ? null : current, + ) + } + > + {page.columns.map((column) => { + const cell = row.cells[column.id]; + return ( + + {cell + ? renderTableCellValue({ + cell, + columnId: column.id, + hovered, + isFirstColumn: !!column.isFirstColumn, + }) + : null} + + ); + })} + + + ); + })} + + {page.columns.length > 0 ? ( + + + Calculate + + + + ) : null} + {page.columns.slice(1).map((column) => ( + + ))} + + + + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/workflow/WorkflowBranchLabel.tsx b/packages/twenty-website-redone/src/app-preview/pages/workflow/WorkflowBranchLabel.tsx new file mode 100644 index 0000000000..b33b9bd9cd --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/workflow/WorkflowBranchLabel.tsx @@ -0,0 +1,55 @@ +import { styled } from '@linaria/react'; + +import { REDUCED_MOTION } from '@/tokens'; +import { THEME_LIGHT } from 'twenty-ui/theme'; + +import { WORKFLOW_THEME } from './workflow-theme'; +import { type WorkflowBranchLabelDef } from '../../types'; + +const colors = WORKFLOW_THEME.colors; + +const BranchLabelPill = styled.div<{ $centered?: boolean }>` + align-items: center; + animation: workflowBranchLabelAppear 320ms ease both; + animation-delay: 700ms; + background: ${colors.nodeSurface}; + border: 1px solid ${colors.nodeBorder}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${colors.textLight}; + display: inline-flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: 11px; + font-weight: ${THEME_LIGHT.font.weight.semiBold}; + height: 20px; + justify-content: center; + min-width: 20px; + padding: 0 4px; + position: absolute; + transform: ${({ $centered }) => + $centered ? 'translate(-50%, -50%)' : 'none'}; + z-index: 2; + + @keyframes workflowBranchLabelAppear { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + ${REDUCED_MOTION} { + animation: none; + } +`; + +export function WorkflowBranchLabel({ text, x, y }: WorkflowBranchLabelDef) { + return ( + + {text} + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/workflow/WorkflowEdges.tsx b/packages/twenty-website-redone/src/app-preview/pages/workflow/WorkflowEdges.tsx new file mode 100644 index 0000000000..f76fd74853 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/workflow/WorkflowEdges.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { useId } from 'react'; + +import { EASING, REDUCED_MOTION } from '@/tokens'; + +import { + getWorkflowEdgePath, + type PositionedWorkflowNode, +} from './workflow-geometry'; +import { WORKFLOW_THEME } from './workflow-theme'; +import { type WorkflowEdgeDef } from '../../types'; + +const colors = WORKFLOW_THEME.colors; +const NODE_HEIGHT = WORKFLOW_THEME.nodeHeightPx; + +const CanvasOverlay = styled.svg` + inset: 0; + overflow: visible; + pointer-events: none; + position: absolute; +`; + +const EdgePath = styled.path` + animation: workflowEdgeDraw 620ms ${EASING.standard} both; + stroke-dasharray: 1; + + @keyframes workflowEdgeDraw { + from { + opacity: 0; + stroke-dashoffset: 1; + } + 25% { + opacity: 1; + } + to { + opacity: 1; + stroke-dashoffset: 0; + } + } + + ${REDUCED_MOTION} { + animation: none; + } +`; + +const PortsLayer = styled.g` + animation: workflowPortsAppear 360ms ease both; + animation-delay: 520ms; + + @keyframes workflowPortsAppear { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + ${REDUCED_MOTION} { + animation: none; + } +`; + +export function WorkflowEdges({ + canvasHeight, + canvasWidth, + edges, + nodes, +}: { + canvasHeight: number; + canvasWidth: number; + edges: ReadonlyArray; + nodes: ReadonlyArray; +}) { + const arrowId = `workflow-arrow-${useId().replace(/:/g, '')}`; + + const topPortNodeIds = new Set(); + const bottomPortNodeIds = new Set(); + const rightPortNodeIds = new Set(); + for (const edge of edges) { + if (edge.type === 'loopRight') { + rightPortNodeIds.add(edge.from); + topPortNodeIds.add(edge.to); + continue; + } + bottomPortNodeIds.add(edge.from); + if (edge.type !== 'loopBack') { + topPortNodeIds.add(edge.to); + } + } + + return ( + + + + + + + {edges.map((edge, index) => ( + + ))} + + {nodes.map((node) => { + const cx = node.x + node.width / 2; + const bottomY = node.y + NODE_HEIGHT + 1; + const rightX = node.x + node.width; + const midY = node.y + NODE_HEIGHT / 2; + return ( + + {topPortNodeIds.has(node.id) ? ( + + ) : null} + {bottomPortNodeIds.has(node.id) ? ( + + ) : null} + {rightPortNodeIds.has(node.id) ? ( + + ) : null} + + ); + })} + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/workflow/WorkflowNode.tsx b/packages/twenty-website-redone/src/app-preview/pages/workflow/WorkflowNode.tsx new file mode 100644 index 0000000000..9c0f914fc8 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/workflow/WorkflowNode.tsx @@ -0,0 +1,183 @@ +import { styled } from '@linaria/react'; +import { + IconClock, + IconCode, + IconFilter, + IconMail, + IconPlug, + IconPlus, + IconRepeat, + IconSearch, + IconSitemap, +} from '@tabler/icons-react'; + +import { EASING, REDUCED_MOTION } from '@/tokens'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { previewFontSize } from '@/app-preview/preview-font-size'; + +import { WORKFLOW_THEME } from './workflow-theme'; +import { PREVIEW_SKELETON } from '../../primitives/PreviewSkeleton'; +import { type WorkflowNodeDef } from '../../types'; + +const colors = WORKFLOW_THEME.colors; + +const NODE_ICON_MAP: Record = { + clock: IconClock, + code: IconCode, + filter: IconFilter, + mail: IconMail, + plug: IconPlug, + plus: IconPlus, + repeat: IconRepeat, + search: IconSearch, + sitemap: IconSitemap, +}; + +const NODE_ICON_COLORS: Record = { + trigger: colors.nodeTriggerIcon, + action: colors.nodeActionIcon, + fallback: colors.nodeIconFallback, +}; + +const Node = styled.div<{ $index: number }>` + align-items: center; + animation: workflowNodeAppear 420ms ${EASING.standard} both; + animation-delay: ${({ $index }) => `${150 + $index * 120}ms`}; + background: ${colors.nodeSurface}; + border: 1px solid ${colors.nodeBorder}; + border-radius: ${THEME_LIGHT.border.radius.md}; + box-sizing: border-box; + display: flex; + gap: 8px; + height: ${WORKFLOW_THEME.nodeHeightPx}px; + left: 0; + padding: 8px; + position: absolute; + top: 0; + z-index: 1; + + @keyframes workflowNodeAppear { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const NodeIconContainer = styled.div` + align-items: center; + animation: workflowNodeContentAppear 320ms ease both; + animation-delay: 80ms; + background: ${colors.nodeIconSurface}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + display: flex; + flex: 0 0 auto; + height: 32px; + justify-content: center; + width: 32px; + + @keyframes workflowNodeContentAppear { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + ${REDUCED_MOTION} { + animation: none; + } +`; + +const NodeContent = styled.div` + align-items: stretch; + align-self: stretch; + animation: workflowNodeContentAppear 320ms ease both; + animation-delay: 80ms; + display: flex; + flex: 1 1 auto; + flex-direction: column; + justify-content: space-between; + max-width: 184px; + min-width: 0; + padding-bottom: 2px; + + ${REDUCED_MOTION} { + animation: none; + } +`; + +const NodeLabel = styled.div` + color: ${colors.textLight}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 11px; + font-weight: ${THEME_LIGHT.font.weight.semiBold}; + line-height: 1; +`; + +const NodeTitle = styled.div` + color: ${colors.textPrimary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.medium}; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const SkeletonIcon = styled(PREVIEW_SKELETON.Block)` + border-radius: ${THEME_LIGHT.border.radius.sm}; + flex: 0 0 auto; + height: 32px; + width: 32px; +`; + +export function WorkflowNode({ + generating = false, + index = 0, + node, +}: { + generating?: boolean; + index?: number; + node: WorkflowNodeDef; +}) { + const Icon = NODE_ICON_MAP[node.iconName] ?? IconCode; + const iconColor = NODE_ICON_COLORS[node.iconColor ?? 'action']; + return ( + + {generating ? ( + <> + + + + + + + ) : ( + <> + + + + + {node.label} + {node.title} + + + )} + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/workflow/WorkflowPage.tsx b/packages/twenty-website-redone/src/app-preview/pages/workflow/WorkflowPage.tsx new file mode 100644 index 0000000000..daa894cd00 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/workflow/WorkflowPage.tsx @@ -0,0 +1,147 @@ +import { styled } from '@linaria/react'; + +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { previewFontSize } from '@/app-preview/preview-font-size'; + +import { WORKFLOW_GRAPH } from './workflow-data'; +import { WorkflowBranchLabel } from './WorkflowBranchLabel'; +import { WorkflowEdges } from './WorkflowEdges'; +import { WorkflowNode } from './WorkflowNode'; +import { WORKFLOW_THEME } from './workflow-theme'; +import { type WorkflowPageDefinition } from '../../types'; + +const colors = WORKFLOW_THEME.colors; + +const PageShell = styled.div` + background: ${colors.canvasBackground}; + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 100%; + min-width: 100%; +`; + +const CanvasViewportShell = styled.div` + background: ${colors.canvasBackground}; + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + position: relative; +`; + +const CanvasViewport = styled.div` + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: auto; +`; + +const Canvas = styled.div<{ $height: number; $width: number }>` + background-color: ${colors.canvasBackground}; + background-image: radial-gradient( + circle, + ${colors.canvasDot} 1px, + transparent 1.2px + ); + background-position: 10px 10px; + background-size: 20px 20px; + box-sizing: border-box; + height: ${({ $height }) => `${$height}px`}; + min-height: 100%; + min-width: 100%; + overflow: hidden; + position: relative; + width: ${({ $width }) => `${$width}px`}; +`; + +const CanvasContent = styled.div<{ $height: number; $width: number }>` + height: ${({ $height }) => `${$height}px`}; + left: calc((100% - ${({ $width }) => `${$width}px`}) / 2); + position: absolute; + top: ${WORKFLOW_THEME.canvasTopOffsetPx}px; + width: ${({ $width }) => `${$width}px`}; +`; + +const ActiveBadge = styled.div` + left: 8px; + pointer-events: none; + position: absolute; + top: 8px; + z-index: 3; +`; + +const ActiveBadgeLabel = styled.span` + align-items: center; + background: ${colors.activeBadgeBackground}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${colors.activeBadgeText}; + display: inline-flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.regular}; + height: 20px; + line-height: 1.4; + padding: 0 8px; +`; + +// The named workflows render the authored default graph; a page may +// carry its own nodes/edges (the type allows it, as on the old site). +export function WorkflowPage({ page }: { page: WorkflowPageDefinition }) { + const nodes = page.nodes ?? WORKFLOW_GRAPH.nodes; + const edges = page.edges ?? (page.nodes ? [] : WORKFLOW_GRAPH.edges); + const branchLabels = + page.branchLabels ?? (page.nodes ? [] : WORKFLOW_GRAPH.branchLabels); + + return ( + + + + Active + + + + + {page.generating ? null : ( + + )} + {nodes.map((node, index) => ( + + ))} + {page.generating + ? null + : branchLabels.map((label) => ( + + ))} + + + + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/workflow/workflow-data.ts b/packages/twenty-website-redone/src/app-preview/pages/workflow/workflow-data.ts new file mode 100644 index 0000000000..3be6d8af89 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/workflow/workflow-data.ts @@ -0,0 +1,131 @@ +// The default node graph (the "Create company when adding a new person" +// workflow), extracted verbatim from the old data. +import { type WorkflowEdgeDef, type WorkflowNodeDef } from '../../types'; + +export const WORKFLOW_GRAPH = { + nodes: [ + { + id: 'trigger', + x: 370, + y: 80, + width: 238, + label: 'Trigger', + title: 'Record is created or updated', + iconName: 'plug', + iconColor: 'trigger', + }, + { + id: 'is-personal-email', + x: 620, + y: 210, + width: 220, + label: 'Action', + title: 'Is this a personal email?', + iconName: 'code', + iconColor: 'action', + }, + { + id: 'if-business-email', + x: 640, + y: 340, + width: 180, + label: 'Action', + title: 'If business email', + iconName: 'filter', + iconColor: 'fallback', + }, + { + id: 'extract-domain', + x: 620, + y: 470, + width: 220, + label: 'Action', + title: 'Extract domain from email', + iconName: 'code', + iconColor: 'action', + }, + { + id: 'search-company', + x: 640, + y: 600, + width: 180, + label: 'Action', + title: 'Search Company', + iconName: 'search', + iconColor: 'fallback', + }, + { + id: 'find-exact-match', + x: 610, + y: 730, + width: 240, + label: 'Action', + title: 'Find exact company match', + iconName: 'code', + iconColor: 'action', + }, + { + id: 'company-already-exists', + x: 600, + y: 860, + width: 260, + label: 'Action', + title: 'If a company already exists', + iconName: 'sitemap', + iconColor: 'fallback', + }, + { + id: 'attach-existing-company', + x: 370, + y: 990, + width: 240, + label: 'Action', + title: 'Attach person to existing company', + iconName: 'repeat', + iconColor: 'fallback', + }, + { + id: 'create-company', + x: 840, + y: 990, + width: 220, + label: 'Action', + title: 'Create a new company', + iconName: 'plus', + iconColor: 'fallback', + }, + { + id: 'attach-created-company', + x: 850, + y: 1120, + width: 240, + label: 'Action', + title: 'Attach person to this company', + iconName: 'repeat', + iconColor: 'fallback', + }, + ] satisfies WorkflowNodeDef[], + edges: [ + { from: 'trigger', to: 'is-personal-email', type: 'curve' }, + { from: 'is-personal-email', to: 'if-business-email', type: 'vertical' }, + { from: 'if-business-email', to: 'extract-domain', type: 'vertical' }, + { from: 'extract-domain', to: 'search-company', type: 'vertical' }, + { from: 'search-company', to: 'find-exact-match', type: 'vertical' }, + { + from: 'find-exact-match', + to: 'company-already-exists', + type: 'vertical', + }, + { + from: 'company-already-exists', + to: 'attach-existing-company', + type: 'branch', + }, + { from: 'company-already-exists', to: 'create-company', type: 'branch' }, + { from: 'create-company', to: 'attach-created-company', type: 'vertical' }, + ] satisfies WorkflowEdgeDef[], + branchLabels: [ + { x: 566, y: 944, text: 'if' }, + { x: 820, y: 944, text: 'else' }, + ], +}; diff --git a/packages/twenty-website-redone/src/app-preview/pages/workflow/workflow-geometry.test.ts b/packages/twenty-website-redone/src/app-preview/pages/workflow/workflow-geometry.test.ts new file mode 100644 index 0000000000..2bf7635f01 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/workflow/workflow-geometry.test.ts @@ -0,0 +1,38 @@ +import { + getWorkflowEdgePath, + type PositionedWorkflowNode, +} from './workflow-geometry'; + +const nodes: PositionedWorkflowNode[] = [ + { id: 'first', width: 100, x: 10, y: 20 }, + { id: 'second', width: 80, x: 120, y: 140 }, +]; + +describe('workflow-geometry', () => { + it('should throw for unknown workflow nodes', () => { + expect(() => + getWorkflowEdgePath({ + edge: { from: 'first', to: 'missing', type: 'vertical' }, + nodes, + }), + ).toThrow('Unknown workflow node: missing'); + }); + + it('should create vertical edge paths from bottom-center to top-center', () => { + expect( + getWorkflowEdgePath({ + edge: { from: 'first', to: 'second', type: 'vertical' }, + nodes, + }), + ).toBe('M60 69 L160 140'); + }); + + it('should create curved edge paths for non-vertical edges', () => { + expect( + getWorkflowEdgePath({ + edge: { from: 'first', to: 'second', type: 'curve' }, + nodes, + }), + ).toBe('M60 69 C60 97 160 112 160 140'); + }); +}); diff --git a/packages/twenty-website-redone/src/app-preview/pages/workflow/workflow-geometry.ts b/packages/twenty-website-redone/src/app-preview/pages/workflow/workflow-geometry.ts new file mode 100644 index 0000000000..259381f49f --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/workflow/workflow-geometry.ts @@ -0,0 +1,124 @@ +import { WORKFLOW_THEME } from './workflow-theme'; +import { type WorkflowEdgeDef } from '../../types'; + +// Pure path math for the node graph's edges, ported verbatim. Ports sit +// at the node's top/bottom center (+1px under the border) and right +// center; every corner turns through an 8px arc. +type Point = { x: number; y: number }; + +export type PositionedWorkflowNode = { + id: string; + x: number; + y: number; + width: number; +}; + +const CORNER_RADIUS = 8; +const NODE_HEIGHT = WORKFLOW_THEME.nodeHeightPx; + +function getNodeById( + nodes: ReadonlyArray, + nodeId: string, +): PositionedWorkflowNode { + const node = nodes.find((workflowNode) => workflowNode.id === nodeId); + if (!node) { + throw new Error(`Unknown workflow node: ${nodeId}`); + } + return node; +} + +function getNodeTopCenter(node: PositionedWorkflowNode): Point { + return { x: node.x + node.width / 2, y: node.y }; +} + +function getNodeBottomCenter(node: PositionedWorkflowNode): Point { + return { x: node.x + node.width / 2, y: node.y + NODE_HEIGHT + 1 }; +} + +function getNodeRightCenter(node: PositionedWorkflowNode): Point { + return { x: node.x + node.width, y: node.y + NODE_HEIGHT / 2 }; +} + +function getRightDownPath( + fromNode: PositionedWorkflowNode, + toNode: PositionedWorkflowNode, +): string { + const start = getNodeRightCenter(fromNode); + const end = getNodeTopCenter(toNode); + const r = CORNER_RADIUS; + return [ + `M${start.x} ${start.y}`, + `L${end.x - r} ${start.y}`, + `A${r} ${r} 0 0 1 ${end.x} ${start.y + r}`, + `L${end.x} ${end.y}`, + ].join(' '); +} + +function getLoopBackPath( + fromNode: PositionedWorkflowNode, + toNode: PositionedWorkflowNode, +): string { + const r = CORNER_RADIUS; + const padRight = 40; + const padBottom = 180; + const start = getNodeBottomCenter(fromNode); + const endX = toNode.x + toNode.width / 2; + const endY = toNode.y + NODE_HEIGHT + 50; + const rightX = fromNode.x + fromNode.width + padRight; + const bottomY = start.y + padBottom; + return [ + `M${start.x} ${start.y}`, + `L${start.x} ${bottomY - r}`, + `A${r} ${r} 0 0 0 ${start.x + r} ${bottomY}`, + `L${rightX - r} ${bottomY}`, + `A${r} ${r} 0 0 0 ${rightX} ${bottomY - r}`, + `L${rightX} ${endY + r}`, + `A${r} ${r} 0 0 0 ${rightX - r} ${endY}`, + `L${endX} ${endY}`, + ].join(' '); +} + +function getSmoothStepPath(start: Point, end: Point): string { + const r = CORNER_RADIUS; + const midY = start.y + (end.y - start.y) * 0.4; + if (Math.abs(start.x - end.x) < 2) { + return `M${start.x} ${start.y} L${end.x} ${end.y}`; + } + const goingRight = end.x > start.x; + return [ + `M${start.x} ${start.y}`, + `L${start.x} ${midY - r}`, + `A${r} ${r} 0 0 ${goingRight ? 0 : 1} ${start.x + (goingRight ? r : -r)} ${midY}`, + `L${end.x - (goingRight ? r : -r)} ${midY}`, + `A${r} ${r} 0 0 ${goingRight ? 1 : 0} ${end.x} ${midY + r}`, + `L${end.x} ${end.y}`, + ].join(' '); +} + +export function getWorkflowEdgePath({ + edge, + nodes, +}: { + edge: WorkflowEdgeDef; + nodes: ReadonlyArray; +}): string { + const fromNode = getNodeById(nodes, edge.from); + const toNode = getNodeById(nodes, edge.to); + if (edge.type === 'loopRight') { + return getRightDownPath(fromNode, toNode); + } + if (edge.type === 'loopBack') { + return getLoopBackPath(fromNode, toNode); + } + const start = getNodeBottomCenter(fromNode); + const end = getNodeTopCenter(toNode); + if (edge.type === 'vertical') { + return `M${start.x} ${start.y} L${end.x} ${end.y}`; + } + if (edge.type === 'smoothStep') { + return getSmoothStepPath(start, end); + } + const controlStartY = start.y + 28; + const controlEndY = end.y - 28; + return `M${start.x} ${start.y} C${start.x} ${controlStartY} ${end.x} ${controlEndY} ${end.x} ${end.y}`; +} diff --git a/packages/twenty-website-redone/src/app-preview/pages/workflow/workflow-theme.ts b/packages/twenty-website-redone/src/app-preview/pages/workflow/workflow-theme.ts new file mode 100644 index 0000000000..6960babaa8 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/pages/workflow/workflow-theme.ts @@ -0,0 +1,28 @@ +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones'; + +// The workflow canvas vocabulary: authored inks from the tones home, +// chrome from the product-derived theme. +export const WORKFLOW_THEME = { + canvasWidthPx: 1480, + canvasHeightPx: 1260, + nodeHeightPx: 48, + canvasTopOffsetPx: 16, + colors: { + activeBadgeBackground: + APP_PREVIEW_TONES.workflowCanvas.activeBadgeBackground, + activeBadgeText: APP_PREVIEW_TONES.workflowCanvas.activeBadgeText, + arrowStroke: APP_PREVIEW_TONES.workflowCanvas.arrowStroke, + canvasBackground: THEME_LIGHT.background.primary, + canvasDot: THEME_LIGHT.border.color.medium, + nodeActionIcon: APP_PREVIEW_TONES.workflowCanvas.actionIcon, + nodeTriggerIcon: APP_PREVIEW_TONES.workflowCanvas.triggerIcon, + nodeIconFallback: THEME_LIGHT.font.color.secondary, + nodeBorder: THEME_LIGHT.border.color.strong, + nodeSurface: THEME_LIGHT.background.secondary, + nodeIconSurface: THEME_LIGHT.background.transparent.light, + textPrimary: THEME_LIGHT.font.color.primary, + textLight: THEME_LIGHT.font.color.light, + textTertiary: THEME_LIGHT.font.color.tertiary, + }, +}; diff --git a/packages/twenty-website-redone/src/app-preview/preview-font-size.ts b/packages/twenty-website-redone/src/app-preview/preview-font-size.ts new file mode 100644 index 0000000000..cfce99365e --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/preview-font-size.ts @@ -0,0 +1,8 @@ +// twenty-front's rem base is 13px (its index.css); the site root is 16px. +// The product mockups render at the product's size, so twenty-ui's rem font +// sizes resolve to absolute px at 13. This is the only font transform — every +// other value is consumed straight from twenty-ui's theme. +const PRODUCT_REM_BASE_PX = 13; + +export const previewFontSize = (themeRemValue: string): string => + `${parseFloat(themeRemValue) * PRODUCT_REM_BASE_PX}px`; diff --git a/packages/twenty-website-redone/src/app-preview/primitives/Chip.tsx b/packages/twenty-website-redone/src/app-preview/primitives/Chip.tsx new file mode 100644 index 0000000000..83df944276 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/primitives/Chip.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { + type KeyboardEvent, + type MouseEvent, + type PointerEvent, + type ReactNode, +} from 'react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; + +import { previewFontSize } from '../preview-font-size'; + +export type ChipVariant = + | 'highlighted' + | 'regular' + | 'transparent' + | 'rounded' + | 'static'; + +type ChipProps = { + className?: string; + clickable?: boolean; + isBold?: boolean; + label: string; + leftComponent?: ReactNode; + maxWidth?: number; + onClick?: () => void; + variant?: ChipVariant; +}; + +// Variant styling lives in data-attribute selectors (not prop functions) so +// every value bakes at build — twenty-ui's theme is tree-shaken out of the +// runtime bundle. Sized against the content box like twenty-front's chip: +// 12px content + 4px padding = 20px. +const StyledContainer = styled.div<{ $maxWidth?: number }>` + --chip-horizontal-padding: ${THEME_LIGHT.spacing(1)}; + --chip-vertical-padding: ${THEME_LIGHT.spacing(1)}; + align-items: center; + background-color: inherit; + border: none; + border-radius: ${THEME_LIGHT.border.radius.sm}; + box-sizing: content-box; + color: ${THEME_LIGHT.font.color.primary}; + cursor: inherit; + display: inline-flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.regular}; + gap: ${THEME_LIGHT.spacing(1)}; + height: ${THEME_LIGHT.spacing(3)}; + justify-content: flex-start; + line-height: 1.4; + max-width: ${({ $maxWidth }) => + $maxWidth + ? `calc(${$maxWidth}px - 2 * var(--chip-horizontal-padding))` + : '100%'}; + overflow: hidden; + padding: var(--chip-vertical-padding) var(--chip-horizontal-padding); + user-select: none; + + & > svg { + flex-shrink: 0; + } + + &[data-bold] { + font-weight: ${THEME_LIGHT.font.weight.medium}; + } + + &[data-clickable] { + cursor: pointer; + } + + &[data-variant='highlighted'] { + background-color: ${THEME_LIGHT.background.transparent.light}; + } + &[data-variant='highlighted']:hover { + background-color: ${THEME_LIGHT.background.transparent.medium}; + } + &[data-variant='highlighted']:active { + background-color: ${THEME_LIGHT.background.transparent.strong}; + } + + &[data-variant='rounded'] { + border-radius: ${THEME_LIGHT.border.radius.pill}; + } + + &[data-variant='static'] { + background-color: ${THEME_LIGHT.background.transparent.lighter}; + border: 1px solid ${THEME_LIGHT.border.color.strong}; + border-radius: ${THEME_LIGHT.border.radius.pill}; + padding-left: ${THEME_LIGHT.spacing(2)}; + padding-right: ${THEME_LIGHT.spacing(2)}; + } + &[data-variant='static']:hover, + &[data-variant='static']:active { + background-color: ${THEME_LIGHT.background.transparent.lighter}; + } + + &[data-variant='transparent'] { + cursor: inherit; + padding-left: 0; + } + + &[data-clickable][data-variant='regular']:hover { + background-color: ${THEME_LIGHT.background.transparent.light}; + } + &[data-clickable][data-variant='regular']:active { + background-color: ${THEME_LIGHT.background.transparent.medium}; + } +`; + +const StyledLabel = styled.span` + color: inherit; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export function Chip({ + label, + clickable = false, + isBold = false, + leftComponent = null, + className, + maxWidth, + onClick, + variant = 'regular', +}: ChipProps) { + const isInteractive = clickable || onClick !== undefined; + const handlePointerDown = (event: PointerEvent) => { + if (!isInteractive) { + return; + } + event.stopPropagation(); + }; + const handleClick = (event: MouseEvent) => { + if (!isInteractive) { + return; + } + event.stopPropagation(); + onClick?.(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (!onClick) { + return; + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onClick(); + } + }; + + return ( + + {leftComponent} + {label} + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/primitives/FaviconLogo.tsx b/packages/twenty-website-redone/src/app-preview/primitives/FaviconLogo.tsx new file mode 100644 index 0000000000..eaf28f607c --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/primitives/FaviconLogo.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { useState } from 'react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; + +import { createBoundedFailureCache } from '@/platform/visuals/engine/bounded-failure-cache'; + +import { getInitials } from './get-initials'; +import { sharedAssetUrls } from '../data/shared-asset-urls'; + +const failedFaviconUrls = createBoundedFailureCache(256); + +const FaviconFrame = styled.div<{ $size: number }>` + align-items: center; + border-radius: ${THEME_LIGHT.border.radius.xs}; + display: flex; + flex: 0 0 auto; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${({ $size }) => ($size <= 14 ? '8px' : '9px')}; + font-weight: ${THEME_LIGHT.font.weight.semiBold}; + height: ${({ $size }) => `${$size}px`}; + justify-content: center; + line-height: 1; + overflow: hidden; + width: ${({ $size }) => `${$size}px`}; +`; + +const FaviconFallbackFrame = styled(FaviconFrame)` + background: ${THEME_LIGHT.border.color.medium}; + color: ${THEME_LIGHT.font.color.secondary}; +`; + +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 getLogoUrlFromDomainName(domainName?: string): string | undefined { + const sharedLogoUrl = sharedAssetUrls.companyLogoForDomain(domainName); + if (sharedLogoUrl) { + return sharedLogoUrl; + } + const sanitizedDomain = sanitizeUrl(domainName); + return sanitizedDomain + ? `https://twenty-icons.com/${sanitizedDomain}` + : undefined; +} + +export function FaviconLogo({ + domain, + label, + size = 14, + src, +}: { + domain?: string; + label?: string; + size?: number; + src?: string; +}) { + const faviconUrl = src ?? getLogoUrlFromDomainName(domain); + const [localFailedUrl, setLocalFailedUrl] = useState(null); + const showFavicon = + faviconUrl !== undefined && + !failedFaviconUrls.has(faviconUrl) && + localFailedUrl !== faviconUrl; + + if (showFavicon) { + return ( + + . + fetchPriority="low" + src={faviconUrl} + onError={() => { + failedFaviconUrls.add(faviconUrl); + setLocalFailedUrl(faviconUrl); + }} + /> + + ); + } + + const initials = label ? getInitials(label) : '?'; + return ( + + {initials.slice(0, 1)} + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/primitives/MiniIcon.tsx b/packages/twenty-website-redone/src/app-preview/primitives/MiniIcon.tsx new file mode 100644 index 0000000000..54874917ca --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/primitives/MiniIcon.tsx @@ -0,0 +1,24 @@ +import { type ComponentType } from 'react'; + +import { THEME_LIGHT } from 'twenty-ui/theme'; + +type MiniIconProps = { + color?: string; + icon: ComponentType<{ + 'aria-hidden'?: boolean; + color?: string; + size?: number; + stroke?: number; + }>; + size?: number; + stroke?: number; +}; + +export function MiniIcon({ + color, + icon: Icon, + size = THEME_LIGHT.icon.size.sm, + stroke = THEME_LIGHT.icon.stroke.sm, +}: MiniIconProps) { + return ; +} diff --git a/packages/twenty-website-redone/src/app-preview/primitives/PersonAvatar.tsx b/packages/twenty-website-redone/src/app-preview/primitives/PersonAvatar.tsx new file mode 100644 index 0000000000..09eae94d1a --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/primitives/PersonAvatar.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { useState } from 'react'; + +import { createBoundedFailureCache } from '@/platform/visuals/engine/bounded-failure-cache'; + +import { PreviewAvatar } from './PreviewAvatar'; +import { type CellPerson } from '../types'; + +const failedAvatarUrls = createBoundedFailureCache(256); + +type PersonIdentity = Pick< + CellPerson, + 'avatarUrl' | 'kind' | 'name' | 'shortLabel' | 'tone' +>; + +const AvatarImage = styled.img` + display: block; + height: 100%; + object-fit: cover; + width: 100%; +`; + +export function PersonAvatar({ + person, + size = 14, +}: { + person: PersonIdentity; + 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 ( + + {showAvatar ? ( + preloads. + fetchPriority="low" + src={person.avatarUrl} + onError={() => { + if (person.avatarUrl) { + failedAvatarUrls.add(person.avatarUrl); + setLocalFailedUrl(person.avatarUrl); + } + }} + /> + ) : ( + (person.shortLabel ?? person.name.trim().charAt(0).toUpperCase()) + )} + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/primitives/PreviewAvatar.tsx b/packages/twenty-website-redone/src/app-preview/primitives/PreviewAvatar.tsx new file mode 100644 index 0000000000..e42090fe5a --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/primitives/PreviewAvatar.tsx @@ -0,0 +1,84 @@ +import { styled } from '@linaria/react'; +import { type ReactNode } from 'react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; + +// The product draws a colored, photo-less avatar from a color4 surface with +// color12 text (twenty-front hashes the name to the tone; the mockup data +// assigns it). Baked per tone from the product ramp — gray is the base and +// the unknown-tone fallback; our `teal` is the product's turquoise. +const AvatarFrame = styled.div<{ $size: number }>` + align-items: center; + background: ${THEME_LIGHT.color.gray4}; + border-radius: 50%; + color: ${THEME_LIGHT.color.gray12}; + display: flex; + flex: 0 0 auto; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${({ $size }) => + $size <= 12 ? '8px' : $size <= 14 ? '10px' : '12px'}; + font-weight: ${THEME_LIGHT.font.weight.medium}; + height: ${({ $size }) => `${$size}px`}; + justify-content: center; + line-height: 1; + overflow: hidden; + width: ${({ $size }) => `${$size}px`}; + + &[data-square] { + border-radius: ${THEME_LIGHT.border.radius.xs}; + } + + &[data-tone='amber'] { + background: ${THEME_LIGHT.color.amber4}; + color: ${THEME_LIGHT.color.amber12}; + } + &[data-tone='blue'] { + background: ${THEME_LIGHT.color.blue4}; + color: ${THEME_LIGHT.color.blue12}; + } + &[data-tone='green'] { + background: ${THEME_LIGHT.color.green4}; + color: ${THEME_LIGHT.color.green12}; + } + &[data-tone='orange'] { + background: ${THEME_LIGHT.color.orange4}; + color: ${THEME_LIGHT.color.orange12}; + } + &[data-tone='pink'] { + background: ${THEME_LIGHT.color.pink4}; + color: ${THEME_LIGHT.color.pink12}; + } + &[data-tone='purple'] { + background: ${THEME_LIGHT.color.purple4}; + color: ${THEME_LIGHT.color.purple12}; + } + &[data-tone='red'] { + background: ${THEME_LIGHT.color.red4}; + color: ${THEME_LIGHT.color.red12}; + } + &[data-tone='teal'] { + background: ${THEME_LIGHT.color.turquoise4}; + color: ${THEME_LIGHT.color.turquoise12}; + } +`; + +export function PreviewAvatar({ + children, + size = 14, + square = false, + tone = 'gray', +}: { + children: ReactNode; + size?: number; + square?: boolean; + tone?: string; +}) { + return ( + + {children} + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/primitives/PreviewIcon.tsx b/packages/twenty-website-redone/src/app-preview/primitives/PreviewIcon.tsx new file mode 100644 index 0000000000..9fd242a232 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/primitives/PreviewIcon.tsx @@ -0,0 +1,201 @@ +import { styled } from '@linaria/react'; +import { + IconBook, + IconBuildingSkyscraper, + IconCalendarEvent, + IconCheckbox, + IconFolder, + IconLayoutDashboard, + IconLink, + IconMapPin, + IconNotes, + IconPlanet, + IconPlayerPlay, + IconRocket, + IconSettings, + IconSettingsAutomation, + IconTargetArrow, + IconUser, + IconVersions, +} from '@tabler/icons-react'; +import { type ReactNode } from 'react'; + +import { APP_PREVIEW_MOTION } from '@/tokens/app-preview/app-preview-motion'; +import { THEME_LIGHT } from 'twenty-ui/theme'; + +import { TwentyLogo } from '@/icons'; + +import { FaviconLogo } from './FaviconLogo'; +import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones'; +import { type SidebarIcon } from '../types'; + +// String keys are the derived standard-object icon names with the Icon +// prefix dropped (the drift check pins the bindings to twenty-server). +const TABLER_ICON_MAP: Record = { + book: IconBook, + buildingSkyscraper: IconBuildingSkyscraper, + calendarEvent: IconCalendarEvent, + checkbox: IconCheckbox, + folder: IconFolder, + layoutDashboard: IconLayoutDashboard, + mapPin: IconMapPin, + notes: IconNotes, + planet: IconPlanet, + playerPlay: IconPlayerPlay, + rocket: IconRocket, + settings: IconSettings, + settingsAutomation: IconSettingsAutomation, + targetArrow: IconTargetArrow, + user: IconUser, + versions: IconVersions, +}; + +const SidebarIconSurface = styled.div<{ + $background: string; + $border: string; + $color: string; + $pulse?: boolean; +}>` + align-items: center; + animation: ${({ $pulse }) => + $pulse + ? `objectAppearIcon 1400ms ${APP_PREVIEW_MOTION.revealPopEase} both` + : 'none'}; + background: ${({ $background }) => $background}; + border: 1px solid ${({ $border }) => $border}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${({ $color }) => $color}; + display: flex; + flex: 0 0 auto; + height: 16px; + justify-content: center; + position: relative; + width: 16px; + + @keyframes objectAppearIcon { + 0% { + transform: scale(0.35) rotate(-18deg); + } + 30% { + transform: scale(1.45) rotate(8deg); + } + 55% { + transform: scale(0.9) rotate(-4deg); + } + 80% { + transform: scale(1.06) rotate(2deg); + } + 100% { + transform: scale(1) rotate(0deg); + } + } +`; + +const SidebarAvatar = styled.div<{ + $background: string; + $color: string; + $shape?: 'circle' | 'square'; +}>` + align-items: center; + background: ${({ $background }) => $background}; + border-radius: ${({ $shape }) => ($shape === 'square' ? '4px' : '999px')}; + color: ${({ $color }) => $color}; + display: flex; + flex: 0 0 auto; + font-family: ${THEME_LIGHT.font.family}; + font-size: 10px; + font-weight: ${THEME_LIGHT.font.weight.medium}; + height: 16px; + justify-content: center; + line-height: 1; + width: 16px; +`; + +const LinkOverlayFrame = styled.div` + align-items: center; + background: ${THEME_LIGHT.border.color.light}; + border-radius: ${THEME_LIGHT.border.radius.xs}; + bottom: -1px; + display: flex; + height: 7px; + justify-content: center; + position: absolute; + right: -1px; + width: 7px; +`; + +function LinkOverlay() { + return ( + + + + ); +} + +export function renderPreviewIcon( + icon: SidebarIcon, + pulse: boolean = false, +): ReactNode { + if (icon.kind === 'brand') { + return ( + + {icon.brand === 'twenty' ? ( + // The official mark is a component — never a raster copy. + + ) : ( + + )} + {icon.overlay === 'link' ? : null} + + ); + } + if (icon.kind === 'avatar') { + const tone = + APP_PREVIEW_TONES.sidebar[icon.tone] ?? APP_PREVIEW_TONES.sidebar.gray; + return ( + + {icon.label} + + ); + } + const tone = + APP_PREVIEW_TONES.sidebar[icon.tone] ?? APP_PREVIEW_TONES.sidebar.gray; + const TablerIcon = TABLER_ICON_MAP[icon.name]; + return ( + + {TablerIcon ? ( + + ) : null} + {icon.overlay === 'link' ? : null} + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/primitives/PreviewRoundedLink.tsx b/packages/twenty-website-redone/src/app-preview/primitives/PreviewRoundedLink.tsx new file mode 100644 index 0000000000..d90bb520d0 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/primitives/PreviewRoundedLink.tsx @@ -0,0 +1,30 @@ +import { styled } from '@linaria/react'; + +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { previewFontSize } from '@/app-preview/preview-font-size'; +import { APP_PREVIEW_CHROME } from '@/app-preview/app-preview-chrome'; + +const LinkPill = styled.span` + align-items: center; + background-color: ${THEME_LIGHT.background.transparent.lighter}; + border: 1px solid ${THEME_LIGHT.border.color.strong}; + border-radius: ${THEME_LIGHT.border.radius.pill}; + color: ${THEME_LIGHT.font.color.primary}; + display: inline-flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.regular}; + gap: ${APP_PREVIEW_CHROME.spacingBasePx}px; + height: 20px; + max-width: 100%; + min-width: 0; + overflow: hidden; + padding: 0 ${APP_PREVIEW_CHROME.spacingBasePx * 2}px; + text-overflow: ellipsis; + user-select: none; + white-space: nowrap; +`; + +export function PreviewRoundedLink({ label }: { label: string }) { + return {label}; +} diff --git a/packages/twenty-website-redone/src/app-preview/primitives/PreviewSkeleton.tsx b/packages/twenty-website-redone/src/app-preview/primitives/PreviewSkeleton.tsx new file mode 100644 index 0000000000..9f3a500a7c --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/primitives/PreviewSkeleton.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { styled } from '@linaria/react'; + +import { REDUCED_MOTION } from '@/tokens'; +import { THEME_LIGHT } from 'twenty-ui/theme'; + +const SkeletonBase = styled.div` + animation: previewSkeletonShimmer 1.4s ease infinite; + background: linear-gradient( + 90deg, + ${THEME_LIGHT.border.color.light} 25%, + ${THEME_LIGHT.background.secondary} 37%, + ${THEME_LIGHT.border.color.light} 63% + ); + background-size: 400% 100%; + + @keyframes previewSkeletonShimmer { + 0% { + background-position: 100% 0; + } + 100% { + background-position: 0 0; + } + } + + ${REDUCED_MOTION} { + animation: none; + } +`; + +const Bar = styled(SkeletonBase)<{ + $height?: number; + $radius?: number; + $width?: string; +}>` + border-radius: ${({ $radius = 4 }) => `${$radius}px`}; + flex-shrink: 0; + height: ${({ $height = 10 }) => `${$height}px`}; + width: ${({ $width = '100%' }) => $width}; +`; + +const Circle = styled(SkeletonBase)<{ $size: number }>` + border-radius: 50%; + flex-shrink: 0; + height: ${({ $size }) => `${$size}px`}; + width: ${({ $size }) => `${$size}px`}; +`; + +const Block = styled(SkeletonBase)<{ $radius?: number }>` + border-radius: ${({ $radius = 6 }) => `${$radius}px`}; + flex: 1; + min-height: 0; + width: 100%; +`; + +export const PREVIEW_SKELETON = { Bar, Block, Circle }; diff --git a/packages/twenty-website-redone/src/app-preview/primitives/PreviewTag.tsx b/packages/twenty-website-redone/src/app-preview/primitives/PreviewTag.tsx new file mode 100644 index 0000000000..f58fad7ceb --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/primitives/PreviewTag.tsx @@ -0,0 +1,41 @@ +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; + +import { previewFontSize } from '../preview-font-size'; +import { type CellSelectColor } from '../types'; + +const TagPill = styled.span<{ $background: string; $color: string }>` + align-items: center; + background: ${({ $background }) => $background}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${({ $color }) => $color}; + display: inline-flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: ${previewFontSize(THEME_LIGHT.font.size.md)}; + font-weight: ${THEME_LIGHT.font.weight.regular}; + height: ${THEME_LIGHT.spacing(5)}; + max-width: 100%; + min-width: 0; + overflow: hidden; + padding: 0 ${THEME_LIGHT.spacing(2)}; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export function PreviewTag({ + color, + label, +}: { + color?: CellSelectColor; + label: string; +}) { + const tagColor = color ?? 'gray'; + return ( + + {label} + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/primitives/get-initials.ts b/packages/twenty-website-redone/src/app-preview/primitives/get-initials.ts new file mode 100644 index 0000000000..7ebd864ed5 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/primitives/get-initials.ts @@ -0,0 +1,9 @@ +export function getInitials(value: string): string { + return value + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join('') + .toUpperCase(); +} diff --git a/packages/twenty-website-redone/src/app-preview/product-visual/AiPanel.tsx b/packages/twenty-website-redone/src/app-preview/product-visual/AiPanel.tsx new file mode 100644 index 0000000000..0cdce75679 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/product-visual/AiPanel.tsx @@ -0,0 +1,379 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { + IconArrowUp, + IconChevronDown, + IconEdit, + IconPaperclip, + IconX, +} from '@tabler/icons-react'; +import { Fragment, useEffect, useRef, type ReactNode } from 'react'; + +import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones'; + +import { ClaudeMark } from '@/icons'; +import { streamedMarkdown } from './streamed-markdown'; +import { type ProductVisualSceneDefinition } from './product-visual-scenes'; + +const MAX_VISIBLE_RESPONSE_CHIPS = 3; + +const inks = APP_PREVIEW_TONES.productVisual; + +const PanelShell = styled.aside<{ $panelOnly: boolean }>` + background: ${THEME_LIGHT.background.primary}; + border: ${({ $panelOnly }) => + $panelOnly ? 'none' : `1px solid ${THEME_LIGHT.border.color.medium}`}; + border-radius: ${({ $panelOnly }) => ($panelOnly ? '0' : '8px')}; + display: flex; + flex-direction: column; + flex-shrink: 0; + height: 100%; + min-height: 0; + overflow: hidden; + width: ${({ $panelOnly }) => ($panelOnly ? '100%' : '280px')}; +`; + +const PanelHeader = styled.div` + align-items: center; + background-color: ${THEME_LIGHT.background.secondary}; + border-bottom: 1px solid ${THEME_LIGHT.border.color.medium}; + display: flex; + flex-shrink: 0; + gap: 4px; + height: 40px; + padding: 0 8px; +`; + +const HeaderButton = styled.span` + align-items: center; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${THEME_LIGHT.font.color.tertiary}; + display: flex; + height: 20px; + justify-content: center; + width: 20px; +`; + +const PanelTitle = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + flex: 1; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 600; + text-align: left; +`; + +const Messages = styled.div` + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; + overflow-y: auto; + padding: 12px; +`; + +const UserMessage = styled.div` + align-self: flex-end; + background: ${inks.userMessageBackground}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${THEME_LIGHT.font.color.secondary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 500; + line-height: 1.5; + padding: 4px 8px; + width: fit-content; +`; + +const AnswerText = styled.div` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 400; + line-height: 1.5; + width: 100%; +`; + +const AnswerStrong = styled.strong` + color: ${THEME_LIGHT.font.color.primary}; + font-weight: 500; +`; + +const AnswerParagraph = styled.div` + line-height: inherit; + margin-block: 8px; + + &:first-child { + margin-block-start: 0; + } + + &:last-child { + margin-block-end: 0; + } +`; + +const EntityChips = styled.div` + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 12px; +`; + +const EntityChip = styled.div` + align-items: center; + background: ${inks.entityChipBackground}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + display: flex; + gap: 4px; + max-width: 100%; + padding: 3px 6px; + width: fit-content; +`; + +const EntityChipIcon = styled.img` + border-radius: ${THEME_LIGHT.border.radius.xs}; + height: 14px; + object-fit: cover; + width: 14px; +`; + +const EntityChipName = styled.span` + color: ${THEME_LIGHT.font.color.primary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 400; + line-height: 1.4; + white-space: nowrap; +`; + +const EntityOverflowChip = styled.div` + align-items: center; + background: ${inks.entityChipBackground}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${THEME_LIGHT.font.color.secondary}; + display: flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 500; + line-height: 1.4; + padding: 3px 6px; +`; + +const ThinkingText = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 12px; + font-weight: 500; +`; + +const InputArea = styled.div` + display: flex; + flex-direction: column; + flex-shrink: 0; + padding: 12px; +`; + +const InputBox = styled.div` + background-color: ${inks.inputBoxBackground}; + border: 1px solid ${THEME_LIGHT.border.color.medium}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + display: flex; + flex-direction: column; + height: 80px; + justify-content: space-between; + min-height: 32px; + padding: 8px; + width: 100%; +`; + +const InputPlaceholder = styled.span` + color: ${THEME_LIGHT.font.color.light}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 13px; + font-weight: 400; + padding: 4px 0; +`; + +const InputButtonRow = styled.div` + align-items: center; + display: flex; + justify-content: space-between; +`; + +const InputLeftButtons = styled.div` + align-items: center; + color: ${THEME_LIGHT.font.color.tertiary}; + display: flex; + gap: 2px; +`; + +const InputRightButtons = styled.div` + align-items: center; + display: flex; + gap: 4px; +`; + +const ModelChip = styled.span` + align-items: center; + border: 1px solid ${THEME_LIGHT.border.color.medium}; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${THEME_LIGHT.font.color.primary}; + display: flex; + font-family: ${THEME_LIGHT.font.family}; + font-size: 12px; + gap: 4px; + padding: 4px 8px; +`; + +const ModelChipChevron = styled.span` + align-items: center; + color: ${THEME_LIGHT.font.color.tertiary}; + display: flex; +`; + +const SendButton = styled.span` + align-items: center; + background: ${THEME_LIGHT.border.color.medium}; + border-radius: 50%; + color: ${THEME_LIGHT.font.color.tertiary}; + display: flex; + height: 20px; + justify-content: center; + width: 20px; +`; + +// The Ask-AI side panel: the user's prompt, the agent's steps (slotted in +// by the playback layer), the streamed answer with entity chips, and the +// composer chrome. Mock copy is product-screenshot fiction (English). +export function AiPanel({ + activeStepIndex = -1, + completedStepCount = 0, + panelOnly = false, + scene, + stepsSlot = null, + streamComplete = false, + streamedTextVisibleLength = 0, +}: { + activeStepIndex?: number; + completedStepCount?: number; + panelOnly?: boolean; + scene: ProductVisualSceneDefinition; + stepsSlot?: ReactNode; + streamComplete?: boolean; + streamedTextVisibleLength?: number; +}) { + const messagesRef = useRef(null); + const hasSteps = (scene.steps?.length ?? 0) > 0; + const responseChips = scene.responseChips; + const visibleResponseChips = responseChips.slice( + 0, + MAX_VISIBLE_RESPONSE_CHIPS, + ); + const hiddenResponseChipCount = Math.max( + responseChips.length - MAX_VISIBLE_RESPONSE_CHIPS, + 0, + ); + // A streamed answer's runs have positional identity; bind the numbers + // ahead of render so keys are explicit data. + const revealedParagraphs = streamedMarkdown + .sliceVisibleParagraphs(scene.responseText, streamedTextVisibleLength) + .map((segments, paragraphNumber) => ({ + number: paragraphNumber, + segments: segments.map((segment, segmentNumber) => ({ + bold: segment.bold, + number: segmentNumber, + text: segment.text, + })), + })); + + // Keep the latest agent step / streamed text in view, like a real chat. + useEffect(() => { + const messages = messagesRef.current; + + if (messages) { + messages.scrollTop = messages.scrollHeight; + } + }, [activeStepIndex, completedStepCount, streamedTextVisibleLength]); + + return ( + + + + + + Ask AI + + + + + + {scene.label} + {stepsSlot} + {streamedTextVisibleLength > 0 ? ( + <> + + {revealedParagraphs.map((paragraph) => ( + + {paragraph.segments.map((segment) => + segment.bold ? ( + + {segment.text} + + ) : ( + {segment.text} + ), + )} + + ))} + + {streamComplete && responseChips.length > 0 ? ( + + {visibleResponseChips.map((chip) => ( + + + {chip.name} + + ))} + {hiddenResponseChipCount > 0 ? ( + + +{hiddenResponseChipCount} more + + ) : null} + + ) : null} + + ) : hasSteps ? null : ( + Thinking... + )} + + + + Ask, search or make anything... + + + + + + + + Claude Opus 4.6 + + + + + + + + + + + + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/product-visual/AiSteps.tsx b/packages/twenty-website-redone/src/app-preview/product-visual/AiSteps.tsx new file mode 100644 index 0000000000..dd15cddec9 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/product-visual/AiSteps.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { THEME_LIGHT } from 'twenty-ui/theme'; +import { + IconChecklist, + IconChevronRight, + IconCpu, + IconFilter, + IconHierarchy3, + IconLayoutList, + IconMail, + IconNotes, + IconSearch, +} from '@tabler/icons-react'; +import { useState } from 'react'; + +import { EASING } from '@/tokens'; + +import { type AgentStep, type AgentToolIcon } from './product-visual-scenes'; + +const TOOL_ICONS: Record = { + search: IconSearch, + filter: IconFilter, + notes: IconNotes, + tasks: IconChecklist, + record: IconLayoutList, + workflow: IconHierarchy3, + mail: IconMail, +}; + +const StepsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +`; + +const StepList = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; + +const StepRow = styled.div` + align-items: center; + animation: aiStepAppear 240ms ${EASING.standard} both; + display: flex; + gap: 8px; + min-height: 20px; + + @keyframes aiStepAppear { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const SummaryButton = styled.button` + align-items: center; + animation: aiSummaryAppear 240ms ${EASING.standard} both; + background: none; + border: none; + border-radius: ${THEME_LIGHT.border.radius.sm}; + color: ${THEME_LIGHT.font.color.tertiary}; + cursor: pointer; + display: flex; + font-family: ${THEME_LIGHT.font.family}; + gap: 8px; + min-height: 18px; + padding: 0; + width: fit-content; + + @keyframes aiSummaryAppear { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const SummaryChevron = styled.span` + align-items: center; + color: ${THEME_LIGHT.font.color.light}; + display: flex; + justify-content: center; + transition: transform 150ms ease-in-out; +`; + +const SummaryText = styled.span` + color: inherit; + font-size: 12px; + font-weight: 400; + line-height: 18px; +`; + +const StepIcon = styled.span` + align-items: center; + color: ${THEME_LIGHT.font.color.light}; + display: flex; + flex-shrink: 0; + justify-content: center; + min-width: 14px; +`; + +const StepLoaderIcon = styled.span` + align-items: center; + color: ${THEME_LIGHT.font.color.tertiary}; + display: flex; + flex-shrink: 0; + justify-content: center; + min-width: 14px; +`; + +const StepLabel = styled.span` + color: ${THEME_LIGHT.font.color.tertiary}; + font-family: ${THEME_LIGHT.font.family}; + font-size: 12px; + font-weight: 400; + line-height: 18px; +`; + +function ThinkingOrbitLoader() { + return ( + + + + ); +} + +// The agent preamble: rows appear as steps run, then collapse behind an +// "N steps" toggle once the answer starts streaming. +export function AiSteps({ + activeStepIndex, + answerStarted, + completedStepCount, + steps, +}: { + activeStepIndex: number; + answerStarted: boolean; + completedStepCount: number; + steps: AgentStep[]; +}) { + const [isExpanded, setIsExpanded] = useState(false); + + const isThinking = activeStepIndex >= 0; + const shouldKeepExpandedBeforeAnswer = !answerStarted; + const shouldShowSummaryButton = + !isThinking && !shouldKeepExpandedBeforeAnswer; + const shouldRenderRows = + isThinking || isExpanded || shouldKeepExpandedBeforeAnswer; + + const stepCount = steps.length; + const visibleCount = + activeStepIndex >= 0 ? activeStepIndex + 1 : completedStepCount; + // Steps run in authored order: position is identity. + const visibleSteps = steps + .slice(0, visibleCount) + .map((step, stepNumber) => ({ step, stepNumber })); + + return ( + + {shouldShowSummaryButton ? ( + setIsExpanded((previousValue) => !previousValue)} + > + + + + + {stepCount === 1 ? '1 step' : `${stepCount} steps`} + + + ) : null} + + {shouldRenderRows ? ( + + {visibleSteps.map(({ step, stepNumber }) => { + const isRunning = stepNumber === activeStepIndex; + + if (step.kind === 'thinking') { + return ( + + {isRunning ? ( + + ) : ( + + + + )} + {isRunning ? 'Thinking' : 'Thought'} + + ); + } + + const ToolIcon = TOOL_ICONS[step.icon]; + + return ( + + + + + {isRunning ? step.running : step.done} + + ); + })} + + ) : null} + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/product-visual/ProductHeroCursor.tsx b/packages/twenty-website-redone/src/app-preview/product-visual/ProductHeroCursor.tsx new file mode 100644 index 0000000000..112e5101a1 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/product-visual/ProductHeroCursor.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { MarkerCursor } from '@/icons'; +import { EASING, fontFamily, FONT_WEIGHT } from '@/tokens'; +import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones'; + +import { observeElementSize } from '@/platform/motion'; + +import { cursorGlide } from './cursor-glide'; +import { type CursorTarget } from './cursor-tour-phases'; +import { type HeroCursorCoordinate } from './hero-cursors'; + +const ROW_X_OFFSET_PX = 80; +const ROW_Y_OFFSET_PX = -5; +const RAIL_X_OFFSET_PX = -4; +const RAIL_Y_OFFSET_PX = -4; +const TAB_X_OFFSET_PX = 2; +const TAB_Y_OFFSET_PX = -6; + +const CURSOR_LABEL_INK = APP_PREVIEW_TONES.productVisual.cursorLabelInk; + +const Overlay = styled.div` + inset: 0; + pointer-events: none; + position: absolute; + z-index: 3; +`; + +const Marker = styled.div<{ + $clicking: boolean; + $glideMs: number; + $hidden: boolean; + $left: number; + $top: number; +}>` + align-items: flex-start; + display: flex; + flex-direction: column; + gap: 6px; + left: ${({ $left }) => `${$left}%`}; + opacity: ${({ $hidden }) => ($hidden ? 0 : 1)}; + position: absolute; + top: ${({ $top }) => `${$top}%`}; + transform: ${({ $clicking }) => ($clicking ? 'scale(0.86)' : 'scale(1)')}; + transform-origin: top left; + transition: + left ${({ $glideMs }) => `${$glideMs}ms`} ${EASING.standard}, + top ${({ $glideMs }) => `${$glideMs}ms`} ${EASING.standard}, + opacity 180ms ease, + transform 150ms ease; + + @media (prefers-reduced-motion: reduce) { + transition: opacity 180ms ease; + } +`; + +const Label = styled.span<{ $color: string }>` + background: ${({ $color }) => $color}; + border-radius: 4px; + color: ${CURSOR_LABEL_INK}; + font-family: ${fontFamily('mono')}; + font-size: 10px; + font-weight: ${FONT_WEIGHT.medium}; + letter-spacing: 0.02em; + line-height: 1; + padding: 4px 8px; + text-transform: uppercase; + width: fit-content; +`; + +const HOME_TARGET: CursorTarget = { kind: 'home' }; + +// One collaborator cursor: glides between its home and measured targets +// (table row, sidebar rail item, record tab) inside the scene overlay. +export function ProductHeroCursor({ + clicking, + color, + glideMs: glideMsOverride, + hidden, + home, + name, + target = HOME_TARGET, +}: { + clicking: boolean; + color: string; + glideMs?: number; + hidden: boolean; + home: HeroCursorCoordinate; + name: string; + target?: CursorTarget; +}) { + const overlayRef = useRef(null); + const [coordinate, setCoordinate] = useState(home); + const [glideMs, setGlideMs] = useState(0); + const coordinateRef = useRef(home); + + const moveTo = useCallback( + (next: HeroCursorCoordinate, explicitMs?: number) => { + const overlayRect = overlayRef.current?.getBoundingClientRect(); + + if (!overlayRect) { + coordinateRef.current = next; + setCoordinate(next); + return; + } + + const distance = cursorGlide.pixelDistance( + coordinateRef.current, + next, + overlayRect, + ); + + if (distance < cursorGlide.SKIP_PX) { + return; + } + + coordinateRef.current = next; + setGlideMs(explicitMs ?? cursorGlide.forDistance(distance)); + setCoordinate(next); + }, + [], + ); + + useEffect(() => { + if (target.kind === 'home') { + moveTo(home); + return undefined; + } + + const selector = + target.kind === 'row' + ? `[data-row-id="${target.id}"]` + : target.kind === 'rail' + ? `[data-rail-item-id="${target.id}"]` + : `[data-record-tab="${target.id}"]`; + + const measure = () => { + const overlay = overlayRef.current; + const scene = overlay?.parentElement; + + if (!overlay || !scene) { + return; + } + + const element = scene.querySelector(selector); + + if (!(element instanceof HTMLElement)) { + return; + } + + const overlayRect = overlay.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + if (elementRect.height === 0 || overlayRect.width === 0) { + return; + } + + const xOffset = + target.kind === 'row' + ? ROW_X_OFFSET_PX + : target.kind === 'rail' + ? RAIL_X_OFFSET_PX + : TAB_X_OFFSET_PX; + const yOffset = + target.kind === 'row' + ? ROW_Y_OFFSET_PX + : target.kind === 'rail' + ? RAIL_Y_OFFSET_PX + : TAB_Y_OFFSET_PX; + const x = + target.kind === 'row' + ? elementRect.left + xOffset - overlayRect.left + : elementRect.left + + elementRect.width / 2 + + xOffset - + overlayRect.left; + const y = + elementRect.top + elementRect.height / 2 + yOffset - overlayRect.top; + + moveTo( + { + left: (x / overlayRect.width) * 100, + top: (y / overlayRect.height) * 100, + }, + glideMsOverride, + ); + }; + + measure(); + window.addEventListener('resize', measure); + + // Targets shift while the page's appear animations play out; re-measure + // when those animations actually finish (and on any scene resize) + // instead of guessing with settle timers. + const scene = overlayRef.current?.parentElement; + scene?.addEventListener('animationend', measure); + const stopObserving = scene ? observeElementSize(scene, measure) : null; + + return () => { + window.removeEventListener('resize', measure); + scene?.removeEventListener('animationend', measure); + stopObserving?.(); + }; + }, [target, home, moveTo, glideMsOverride]); + + return ( + + ); +} diff --git a/packages/twenty-website-redone/src/app-preview/product-visual/ProductVisual.tsx b/packages/twenty-website-redone/src/app-preview/product-visual/ProductVisual.tsx new file mode 100644 index 0000000000..71f7d7eaa9 --- /dev/null +++ b/packages/twenty-website-redone/src/app-preview/product-visual/ProductVisual.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +import { mediaUp, spacing } from '@/tokens'; +import { APP_PREVIEW_STAGE } from '@/tokens/app-preview/app-preview-stage'; + +import { AiPanel } from './AiPanel'; +import { AiSteps } from './AiSteps'; +import { ANTHROPIC_RECORD_PAGE } from './anthropic-record-page'; +import { HERO_CURSORS } from './hero-cursors'; +import { ProductHeroCursor } from './ProductHeroCursor'; +import { useProductHeroCursorAutoplay } from './use-product-hero-cursor-autoplay'; +import { useProductVisualAutoplay } from './use-product-visual-autoplay'; +import { APP_PREVIEW_CONFIG } from '../data/sidebar-config'; +import { PreviewAppLayout } from '../shell/PreviewAppLayout'; +import { ProductFrame } from '../stage/ProductFrame'; + +// How the window sits in its scene: a bleed mount keeps the full window +// width and runs off narrow viewports; a fluid mount reflows the board +// under the scene cap; a panel mount is the phone-width AI chat. +type ProductVisualPresentation = 'bleed' | 'fluid' | 'panel'; + +const PANEL_ONLY_WIDTH_PX = 320; + +const VisualRoot = styled.div<{ $fill: boolean }>` + display: ${({ $fill }) => ($fill ? 'flex' : 'block')}; + flex: ${({ $fill }) => ($fill ? '1' : 'none')}; + flex-direction: column; + isolation: isolate; + margin-top: ${spacing(5)}; + min-height: 0; + position: relative; + text-align: left; + width: 100%; + + ${mediaUp('md')} { + margin-top: ${spacing(11)}; + } +`; + +// The scene box owns the window geometry (single source — the frame +// just fills it). +const ShellScene = styled.div<{ $presentation: ProductVisualPresentation }>` + flex: 0 0 auto; + height: ${APP_PREVIEW_STAGE.windowScene.heightPx}px; + margin: 0 auto; + max-width: ${({ $presentation }) => + $presentation === 'panel' + ? `${PANEL_ONLY_WIDTH_PX}px` + : $presentation === 'fluid' + ? `${APP_PREVIEW_STAGE.windowScene.widthPx}px` + : 'none'}; + min-height: 0; + position: relative; + width: ${({ $presentation }) => + $presentation === 'bleed' + ? `${APP_PREVIEW_STAGE.windowScene.widthPx}px` + : '100%'}; +`; + +// The product mockup staged for the product hero: either one AI scene +// playing beside the Ask-AI panel, or (collaborative) three cursors +// touring the Anthropic record with the panel hidden. +export function ProductVisual({ + activeScene, + collaborative = false, + compactCursorTour = false, + cursorActive = true, + cursorLayer, + fill = false, + playbackEnabled = true, + presentation = 'fluid', +}: { + activeScene?: number; + collaborative?: boolean; + compactCursorTour?: boolean; + cursorActive?: boolean; + cursorLayer?: HTMLElement | null; + fill?: boolean; + playbackEnabled?: boolean; + presentation?: ProductVisualPresentation; +}) { + const { + activeItem, + activeItemId, + activeStepIndex, + agentSteps, + completedStepCount, + displayPage, + highlightedItemId, + openFolderIds, + revealedObjectIds, + selectPageItem, + selectedScene, + sidebarEntries, + streamComplete, + streamedTextVisibleLength, + toggleFolder, + } = useProductVisualAutoplay(APP_PREVIEW_CONFIG, { + externalScene: activeScene, + playbackEnabled, + }); + + const heroCursor = useProductHeroCursorAutoplay( + collaborative && cursorActive, + { mobile: compactCursorTour }, + ); + + // The tour drives the page the collaborators look at. + useEffect(() => { + if (collaborative) { + selectPageItem(heroCursor.pageItemId); + } + }, [collaborative, heroCursor.pageItemId, selectPageItem]); + + const effectivePage = + collaborative && heroCursor.showRecord + ? { ...ANTHROPIC_RECORD_PAGE, activeTabLabel: heroCursor.recordTab } + : displayPage; + const panelOnly = presentation === 'panel'; + const navbarLabel = + effectivePage.type === 'record' + ? effectivePage.header.title + : activeItem.label; + const compactWorkflowPage = + effectivePage.type === 'workflow' && effectivePage.nodes === undefined; + const desktopSidebarMode = collaborative + ? 'collapsed' + : (selectedScene.sidebarMode ?? 'collapsed'); + + const rightAside = collaborative ? undefined : ( + 0 ? ( + 0} + completedStepCount={completedStepCount} + steps={agentSteps} + /> + ) : null + } + streamComplete={streamComplete} + streamedTextVisibleLength={streamedTextVisibleLength} + /> + ); + + const cursors = + collaborative && cursorLayer + ? createPortal( + HERO_CURSORS.map((cursorConfig, index) => { + const isActive = index === heroCursor.activeCursor; + + return ( +