snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -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 = (
<PreviewAppLayout
activeItem={activeItem}
activeItemId={activeItemId}
favorites={APP_PREVIEW_CONFIG.sidebar.favorites}
highlightedItemId={highlightedItemId}
navbarActions={activePage.header.navbarActions}
navbarLabel={activeItem.label}
onSelectPageItem={experience.selectPageItem}
onToggleFolder={experience.toggleFolder}
openFolderIds={experience.openFolderIds}
page={activePage}
revealedObjectIds={revealedObjectIds}
workspaceEntries={sidebarEntries}
/>
);
if (mode === 'static') {
return <ProductFrame>{appShell}</ProductFrame>;
}
return (
<WindowOrderProvider>
<AppWindow>{appShell}</AppWindow>
<Terminal
onChatReset={handleChatReset}
onJumpToConversationEnd={handleJumpToConversationEnd}
onObjectCreated={handleObjectCreated}
/>
</WindowOrderProvider>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, NavbarAction[]> = {
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' },
],
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string,...>).
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<string, string> = {
'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];
},
};

View File

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

View File

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

View File

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

View File

@@ -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 <TablePage page={page} />;
case 'kanban':
return <KanbanPage page={page} />;
case 'workflow':
return <WorkflowPage page={page} />;
case 'dashboard':
return <DashboardPage page={page} />;
case 'record':
return <RecordPage page={page} />;
default:
return null;
}
}

View File

@@ -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: var(--font-product), sans-serif;
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 (
<ChartFrame>
<PlotArea>
<Gridlines $bottom={0} aria-hidden>
{GRID_LINES.map((line) => (
<GridLine key={line} />
))}
</Gridlines>
<LineSvg
aria-hidden
preserveAspectRatio="none"
viewBox={`0 0 ${LINE_W} ${LINE_H}`}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={ACCENT} stopOpacity="0.22" />
<stop offset="100%" stopColor={ACCENT} stopOpacity="0" />
</linearGradient>
</defs>
<path
className="line-area"
d={areaPath}
fill={`url(#${gradientId})`}
style={{ opacity: drawn ? 1 : 0 }}
/>
<path
className="line-stroke"
d={linePath}
fill="none"
stroke={ACCENT}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
style={{ opacity: drawn ? 1 : 0 }}
vectorEffect="non-scaling-stroke"
/>
</LineSvg>
</PlotArea>
</ChartFrame>
);
}
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 (
<ChartFrame>
<PlotArea>
<Gridlines $bottom={22} aria-hidden>
{GRID_LINES.map((line) => (
<GridLine key={line} />
))}
</Gridlines>
<BarColumns>
{data.bars.map((bar, index) => (
<BarColumn key={bar.label}>
<BarTrack>
<Bar $heightPct={(bar.value / max) * 100} $index={index} />
</BarTrack>
<AxisLabel>{bar.label}</AxisLabel>
</BarColumn>
))}
</BarColumns>
</PlotArea>
</ChartFrame>
);
}
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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 (
<DonutWrap>
<DonutFigure>
<DonutSvg aria-hidden viewBox="0 0 36 36">
{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 (
<circle
className="donut-slice"
cx="18"
cy="18"
fill="none"
key={slice.label}
pathLength={100}
r={DONUT_RADIUS}
stroke={slice.color}
strokeDasharray={`${drawn} ${100 - drawn}`}
strokeDashoffset={offset}
strokeLinecap="round"
strokeWidth="3.4"
style={
{
'--slice-pct': drawn,
'--slice-rest': 100 - drawn,
animationDelay: `${index * 120}ms`,
} as CSSProperties
}
/>
);
})}
</DonutSvg>
<DonutCenter>
<DonutValue>{data.centerValue}</DonutValue>
<DonutLabel>{data.centerLabel}</DonutLabel>
</DonutCenter>
</DonutFigure>
<DonutLegend>
{data.slices.map((slice) => (
<LegendRow key={slice.label}>
<LegendDot $color={slice.color} />
<LegendLabel>{slice.label}</LegendLabel>
</LegendRow>
))}
</DonutLegend>
</DonutWrap>
);
}
export const DASHBOARD_CHARTS = {
Line: DashboardLineChart,
Bar: DashboardBarChart,
Donut: DashboardDonutChart,
};

View File

@@ -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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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 (
<KpiCard>
<WidgetTitle>{kpi.title}</WidgetTitle>
<KpiValueRow>
<KpiValue>{kpi.value}</KpiValue>
{kpi.trend ? (
<KpiTrend $up={isUp}>
{isUp ? (
<IconTrendingUp size={14} stroke={THEME_LIGHT.icon.stroke.md} />
) : (
<IconTrendingDown size={14} stroke={THEME_LIGHT.icon.stroke.md} />
)}
{kpi.trend.value}
</KpiTrend>
) : null}
</KpiValueRow>
</KpiCard>
);
}
function KpiSkeleton() {
return (
<KpiCard>
<PREVIEW_SKELETON.Bar $height={11} $width="55%" />
<PREVIEW_SKELETON.Bar $height={20} $width="45%" />
</KpiCard>
);
}
function ChartSkeleton() {
return (
<>
<PREVIEW_SKELETON.Bar $height={11} $width="48%" />
<PREVIEW_SKELETON.Block />
</>
);
}
export function DashboardPage({ page }: { page: DashboardPageDefinition }) {
const { kpis, lineChart, barChart, donutChart, generating } = page.dashboard;
return (
<DashboardGrid>
{kpis.length > 0 ? (
<KpiStack>
{kpis.map((kpi) =>
generating ? (
<KpiSkeleton key={kpi.id} />
) : (
<KpiWidget key={kpi.id} kpi={kpi} />
),
)}
</KpiStack>
) : null}
{lineChart ? (
<LineCard>
{generating ? (
<ChartSkeleton />
) : (
<>
<WidgetTitle>{lineChart.title}</WidgetTitle>
<DASHBOARD_CHARTS.Line data={lineChart} />
</>
)}
</LineCard>
) : null}
{barChart ? (
<BarCard>
{generating ? (
<ChartSkeleton />
) : (
<>
<WidgetTitle>{barChart.title}</WidgetTitle>
<DASHBOARD_CHARTS.Bar data={barChart} />
</>
)}
</BarCard>
) : null}
{donutChart ? (
<DonutCard>
{generating ? (
<ChartSkeleton />
) : (
<>
<WidgetTitle>{donutChart.title}</WidgetTitle>
<DASHBOARD_CHARTS.Donut data={donutChart} />
</>
)}
</DonutCard>
) : null}
</DashboardGrid>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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 (
<Chip
clickable={false}
label={entity.name}
leftComponent={<FaviconLogo domain={entity.domain} label={entity.name} />}
maxWidth={152}
variant="highlighted"
/>
);
}
function PersonChip({ person }: { person: CellPerson }) {
return (
<Chip
clickable={false}
label={person.name}
leftComponent={<PersonAvatar person={person} />}
maxWidth={152}
variant="highlighted"
/>
);
}
function FieldRow({
icon: Icon,
children,
}: {
icon: ComponentType<{
'aria-hidden'?: boolean;
color?: string;
size?: number;
stroke?: number;
}>;
children: ReactNode;
}) {
return (
<FieldRowShell>
<FieldIcon>
<Icon
aria-hidden
color={THEME_LIGHT.font.color.tertiary}
size={16}
stroke={THEME_LIGHT.icon.stroke.sm}
/>
</FieldIcon>
<FieldValueWrap>{children}</FieldValueWrap>
</FieldRowShell>
);
}
export function KanbanCard({ card }: { card: KanbanCardData }) {
return (
<Card>
<CardHeader>
<TitleSlot>
<Chip
clickable={false}
label={card.title}
leftComponent={
<PreviewAvatar tone={toneForTitle(card.title)}>
{card.title.trim().charAt(0).toUpperCase()}
</PreviewAvatar>
}
variant="transparent"
/>
</TitleSlot>
{card.checked ? (
<CheckboxContainer>
<CheckboxBox $checked>
<IconCheck
aria-hidden
color={THEME_LIGHT.font.color.secondary}
size={10}
stroke={THEME_LIGHT.icon.stroke.sm}
/>
</CheckboxBox>
</CheckboxContainer>
) : null}
</CardHeader>
<CardFields>
<FieldRow icon={IconCurrencyDollar}>
<FieldText>{card.amount}</FieldText>
</FieldRow>
<FieldRow icon={IconBuildingSkyscraper}>
<EntityChip entity={card.company} />
</FieldRow>
<FieldRow icon={IconUserCircle}>
<PersonChip person={card.accountOwner} />
</FieldRow>
<FieldRow icon={IconStar}>
<StarsRow>
{Array.from({ length: 5 }, (_, index) => (
<StarGlyph key={index}>
<RatingStar
fillColor={
index < card.rating
? THEME_LIGHT.font.color.secondary
: THEME_LIGHT.border.color.strong
}
/>
</StarGlyph>
))}
</StarsRow>
</FieldRow>
<FieldRow icon={IconCalendarEvent}>
<FieldText>{card.date}</FieldText>
</FieldRow>
<FieldRow icon={IconUser}>
<PersonChip person={card.mainContact} />
</FieldRow>
<FieldRow icon={IconId}>
<FieldText>{card.recordId}</FieldText>
</FieldRow>
</CardFields>
</Card>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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 (
<SkeletonCardShell $index={index}>
<PREVIEW_SKELETON.Bar $height={9} $width="64%" />
{SKELETON_FIELD_WIDTHS.map((width) => (
<SkeletonCardField key={width}>
<PREVIEW_SKELETON.Circle $size={16} />
<PREVIEW_SKELETON.Bar $height={8} $width={width} />
</SkeletonCardField>
))}
</SkeletonCardShell>
);
}
export function KanbanLane({
lane,
index = 0,
isLast,
generating = false,
}: {
generating?: boolean;
index?: number;
isLast: boolean;
lane: KanbanLaneData;
}) {
const skeletonCardCount = 2 + (index % 2);
return (
<Lane $index={index} $last={isLast}>
<LaneHeader>
<LaneTag data-tone={lane.tone}>{lane.label}</LaneTag>
{generating ? null : <LaneCount>{lane.cards.length}</LaneCount>}
</LaneHeader>
<LaneBody>
{generating
? Array.from({ length: skeletonCardCount }, (_, cardIndex) => (
<SkeletonCard index={cardIndex} key={cardIndex} />
))
: lane.cards.map((card) => <KanbanCard card={card} key={card.id} />)}
<AddCardButton aria-hidden>
<MiniIcon
icon={IconPlus}
color={THEME_LIGHT.font.color.light}
size={12}
/>
New
</AddCardButton>
</LaneBody>
</Lane>
);
}

View File

@@ -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 (
<BoardShell
aria-label={`Interactive preview of the ${page.header.title} board`}
>
<BoardCanvas $laneCount={page.lanes.length}>
{page.lanes.map((lane, index) => (
<KanbanLane
generating={page.generating}
index={index}
isLast={index === page.lanes.length - 1}
key={lane.id}
lane={lane}
/>
))}
</BoardCanvas>
</BoardShell>
);
}

View File

@@ -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 (
<AvatarStack>
{people.slice(0, 3).map((person, index) => (
<AvatarWrap $index={index} key={person.name}>
<PersonAvatar person={{ ...person, kind: 'person' }} size={size} />
</AvatarWrap>
))}
</AvatarStack>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
font-size: 13px;
gap: 4px;
`;
const CalTitle = styled.div`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
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 (
<TabSection>
<TabHeader>
<TabHeaderLabel>
<TabHeaderTitle>June</TabHeaderTitle>
<TabHeaderCount>
{calendar.reduce((total, day) => total + day.events.length, 0)}
</TabHeaderCount>
</TabHeaderLabel>
<TabAddButton>
<IconPlus size={14} stroke={2} />
Add event
</TabAddButton>
</TabHeader>
<ListCard>
{calendar.map((day, index) => (
<CalendarDayRow $index={index} key={day.id}>
<DayBadge>
<WeekDay>{day.weekday}</WeekDay>
<MonthDay>{day.day}</MonthDay>
</DayBadge>
<DayEvents>
{day.events.map((event) => (
<CalEventRow key={event.id}>
<AttendanceBar $active={event.attending} />
<CalLabels>
<CalTime>
{event.start}
<IconArrowRight
size={14}
stroke={THEME_LIGHT.icon.stroke.sm}
/>
{event.end}
</CalTime>
<CalTitle>{event.title}</CalTitle>
</CalLabels>
<AvatarGroup people={event.participants} size={16} />
</CalEventRow>
))}
</DayEvents>
</CalendarDayRow>
))}
</ListCard>
</TabSection>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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 (
<TabSection>
<TabHeader>
<TabHeaderLabel>
<TabHeaderTitle>Inbox</TabHeaderTitle>
<TabHeaderCount>{emails.length}</TabHeaderCount>
</TabHeaderLabel>
<TabAddButton>
<IconPlus size={14} stroke={2} />
Compose
</TabAddButton>
</TabHeader>
<ListCard>
{emails.map((email, index) => (
<ActivityRowBox $index={index} key={email.id}>
<EmailHeading>
<AvatarGroup people={email.participants} size={16} />
<SenderNames>
{email.participants.map((person) => person.name).join(', ')}
</SenderNames>
<ThreadCount>{email.count}</ThreadCount>
</EmailHeading>
<SubjectBody>
<EmailSubject>{email.subject}</EmailSubject>
<EmailBody>{email.body}</EmailBody>
</SubjectBody>
<ReceivedAt>{email.date}</ReceivedAt>
</ActivityRowBox>
))}
</ListCard>
</TabSection>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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 <IconLink aria-hidden size={16} stroke={stroke} />;
case 'user':
return <IconUser aria-hidden size={16} stroke={stroke} />;
case 'mapPin':
return <IconMapPin aria-hidden size={16} stroke={stroke} />;
case 'check':
return <IconCheck aria-hidden size={16} stroke={stroke} />;
case 'currency':
return <IconCurrencyDollar aria-hidden size={16} stroke={stroke} />;
case 'linkedin':
return <IconBrandLinkedin aria-hidden size={16} stroke={stroke} />;
case 'twitter':
return <IconBrandX aria-hidden size={16} stroke={stroke} />;
default:
return null;
}
}
function FieldValueRenderer({ field }: { field: RecordField }) {
switch (field.value.type) {
case 'text':
return (
<FieldValueSlot>
<FieldValue>{field.value.value}</FieldValue>
</FieldValueSlot>
);
case 'boolean':
return (
<FieldValueSlot>
<FieldValue>{field.value.value ? 'True' : 'False'}</FieldValue>
</FieldValueSlot>
);
case 'currency':
return (
<FieldValueSlot>
<FieldValue>{field.value.value}</FieldValue>
</FieldValueSlot>
);
case 'link':
return (
<FieldValueSlot>
<PreviewRoundedLink label={field.value.label ?? field.value.value} />
</FieldValueSlot>
);
case 'person':
return (
<FieldValueSlot>
<FieldValuePerson>
<PersonAvatar person={field.value} size={16} />
<FieldValuePersonName>{field.value.name}</FieldValuePersonName>
</FieldValuePerson>
</FieldValueSlot>
);
case 'select':
return (
<FieldValueSlot>
<PreviewTag color={field.value.color} label={field.value.value} />
</FieldValueSlot>
);
default:
return null;
}
}
export const recordFieldValue = { FieldIconGlyph, FieldValueRenderer };

View File

@@ -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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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 (
<TabSection>
<TabHeader>
<TabHeaderLabel>
<TabHeaderTitle>All</TabHeaderTitle>
<TabHeaderCount>{files.length}</TabHeaderCount>
</TabHeaderLabel>
<TabAddButton>
<IconPlus size={14} stroke={2} />
Add file
</TabAddButton>
</TabHeader>
<ListCard>
{files.map((file, index) => {
const { Icon, color } = FILE_ICONS[file.category];
return (
<ActivityRowBox $index={index} key={file.id}>
<FileIconChip $color={color}>
<Icon size={14} stroke={THEME_LIGHT.icon.stroke.sm} />
</FileIconChip>
<FileName>{file.name}</FileName>
<FileDate>
<IconCalendar size={16} stroke={THEME_LIGHT.icon.stroke.sm} />
<FileDateText>{file.date}</FileDateText>
</FileDate>
<FileDots>
<IconDots size={16} stroke={THEME_LIGHT.icon.stroke.sm} />
</FileDots>
</ActivityRowBox>
);
})}
</ListCard>
</TabSection>
);
}

View File

@@ -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: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 600;
`;
const AddNoteButton = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 1.35;
`;
const NoteBody = styled.div`
color: ${THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
font-size: 12px;
line-height: 1.4;
`;
const NoteRelationName = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
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 (
<>
<NotesHeader>
<NotesCount>All {notes.length}</NotesCount>
<AddNoteButton>+ Add note</AddNoteButton>
</NotesHeader>
<NotesGrid>
{notes.map((note, index) => (
<NoteCard
$highlighted={note.highlighted}
$index={index}
$muted={hasHighlightedNotes && !note.highlighted}
key={note.id}
>
<NoteContent>
<NoteTitle>{note.title}</NoteTitle>
<NoteBody>{note.body}</NoteBody>
</NoteContent>
{note.relation ? (
<NoteRelation>
<NoteRelationArrow
fill="none"
stroke={THEME_LIGHT.font.color.tertiary}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
viewBox="0 0 12 12"
>
<path d="M3.5 8.5L8.5 3.5M8.5 3.5H4.5M8.5 3.5V7.5" />
</NoteRelationArrow>
<NoteRelationLabel>Relations:</NoteRelationLabel>
{note.relation.avatarUrl ? (
<NoteRelationAvatar
alt={note.relation.name}
fetchPriority="low"
src={note.relation.avatarUrl}
/>
) : null}
<NoteRelationName>{note.relation.name}</NoteRelationName>
</NoteRelation>
) : null}
</NoteCard>
))}
</NotesGrid>
</>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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 (
<Shell>
<LeftPanel>
<RecordHeader>
<FaviconLogo
domain={record.logoDomain}
label={record.name}
size={48}
/>
<RecordName>{record.name}</RecordName>
<RecordMeta>{record.createdAt}</RecordMeta>
</RecordHeader>
<FieldList>
{record.fields.map((field, index) => (
<FieldRow $index={index} key={field.label}>
<FieldMeta>
<FieldIcon>
{field.icon ? <FieldIconGlyph iconName={field.icon} /> : null}
</FieldIcon>
<FieldLabel>{field.label}</FieldLabel>
</FieldMeta>
<FieldValueRenderer field={field} />
</FieldRow>
))}
</FieldList>
{record.moreCount ? (
<MoreToggle>
<IconChevronDown size={12} stroke={THEME_LIGHT.icon.stroke.sm} />
More ({record.moreCount})
</MoreToggle>
) : null}
<Divider />
{record.relations.map((section) => (
<RelationSection key={section.title}>
<RelationTitle>
{section.title}
{section.count ? (
<RelationTitleCount>All ({section.count})</RelationTitleCount>
) : null}
</RelationTitle>
{section.items.map((item) => (
<RelationItem
$highlighted={item.highlighted}
$muted={hasHighlightedRelations && !item.highlighted}
key={item.name}
>
{item.avatarUrl ? (
<RelationAvatarImage
alt={item.name}
fetchPriority="low"
src={item.avatarUrl}
/>
) : (
<FaviconLogo
domain={item.domain}
label={item.name}
size={16}
/>
)}
<RelationName>{item.name}</RelationName>
</RelationItem>
))}
</RelationSection>
))}
</LeftPanel>
<CenterPanel>
<TabBar>
{recordTabs.LIST.map((tab) => {
const isActive = tab.label === activeTabLabel;
return (
<Tab
key={tab.label}
$active={isActive}
$clickable={isInteractive}
data-record-tab={tab.label}
onClick={() => handleTabClick(tab.label)}
>
<TabInner>
<tab.Icon size={16} stroke={THEME_LIGHT.icon.stroke.sm} />
{tab.label}
</TabInner>
</Tab>
);
})}
</TabBar>
{activeTabLabel === 'Timeline' && timeline ? (
<RecordTimeline timeline={timeline} />
) : null}
{activeTabLabel === 'Tasks' && tasks ? (
<RecordTasks tasks={tasks} />
) : null}
{activeTabLabel === 'Notes' ? <RecordNotes notes={notes} /> : null}
{activeTabLabel === 'Files' && files ? (
<RecordFiles files={files} />
) : null}
{activeTabLabel === 'Emails' && emails ? (
<RecordEmails emails={emails} />
) : null}
{activeTabLabel === 'Calendar' && calendar ? (
<RecordCalendar calendar={calendar} />
) : null}
</CenterPanel>
</Shell>
);
}

View File

@@ -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: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 600;
`;
const TabHeaderCount = styled.span`
color: ${THEME_LIGHT.font.color.light};
font-family: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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,
};

View File

@@ -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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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 (
<TabSection>
<TabHeader>
<TabHeaderLabel>
<TabHeaderTitle>To do</TabHeaderTitle>
<TabHeaderCount>{tasks.length}</TabHeaderCount>
</TabHeaderLabel>
<TabAddButton>
<IconPlus size={14} stroke={2} />
Add task
</TabAddButton>
</TabHeader>
<ListCard>
{tasks.map((task, index) => (
<ActivityRowBox $index={index} key={task.id}>
<TaskLeft>
<TaskCheckbox $done={task.done}>
{task.done ? <IconCheck size={11} stroke={3} /> : null}
</TaskCheckbox>
<TaskTitle $done={task.done}>{task.title}</TaskTitle>
<TaskBody>{task.body}</TaskBody>
</TaskLeft>
<TaskRight>
<DueDate>
<IconCalendar size={16} stroke={THEME_LIGHT.icon.stroke.sm} />
{task.due}
</DueDate>
<TargetChip>
{task.target.domain ? (
<FaviconLogo
domain={task.target.domain}
label={task.target.name}
size={16}
/>
) : (
<PersonAvatar
person={{ ...task.target, kind: 'person' }}
size={16}
/>
)}
<TargetChipName>{task.target.name}</TargetChipName>
</TargetChip>
</TaskRight>
</ActivityRowBox>
))}
</ListCard>
</TabSection>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
`;
const TimelineAction = styled.span`
color: ${THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
white-space: nowrap;
`;
const TimelineSubject = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
`;
const TimelineDiffLabel = styled.span`
color: ${THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
white-space: nowrap;
`;
const TimelineArrow = styled.span`
color: ${THEME_LIGHT.font.color.tertiary};
font-family: var(--font-product), sans-serif;
font-size: 13px;
`;
const TimelineValueText = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
font-size: 13px;
font-weight: 500;
`;
const TimelineCardText = styled.div`
color: ${THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
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 <IconCirclePlus size={16} stroke={stroke} />;
case 'updated':
return <IconEditCircle size={16} stroke={stroke} />;
case 'note':
return <IconNotes size={16} stroke={stroke} />;
case 'calendar':
return <IconCalendarEvent size={16} stroke={stroke} />;
default:
return null;
}
}
function TimelineDiffValue({ value }: { value: RecordFieldValue }) {
switch (value.type) {
case 'select':
return <PreviewTag color={value.color} label={value.value} />;
case 'person':
return (
<TimelineDiffPerson>
<PersonAvatar person={value} size={16} />
<TimelineValueText>{value.name}</TimelineValueText>
</TimelineDiffPerson>
);
case 'boolean':
return (
<TimelineValueText>{value.value ? 'True' : 'False'}</TimelineValueText>
);
case 'currency':
case 'text':
return <TimelineValueText>{value.value}</TimelineValueText>;
case 'link':
return (
<TimelineValueText>{value.label ?? value.value}</TimelineValueText>
);
default:
return null;
}
}
function TimelineEventSummary({ event }: { event: TimelineEvent }) {
const stroke = THEME_LIGHT.icon.stroke.sm;
switch (event.kind) {
case 'created':
return (
<>
<TimelineSubject>{event.subject}</TimelineSubject>
<TimelineAction>was created by</TimelineAction>
<TimelineActor>{event.actor}</TimelineActor>
</>
);
case 'note':
return (
<>
<TimelineActor>{event.actor}</TimelineActor>
<TimelineAction>created a note</TimelineAction>
<TimelineLinkedTitle>{event.title}</TimelineLinkedTitle>
</>
);
case 'calendar':
return (
<>
<TimelineActor>{event.actor}</TimelineActor>
<TimelineAction>added a calendar event</TimelineAction>
<TimelineSubject>{event.title}</TimelineSubject>
<TimelineToggle>
<IconChevronUp size={12} stroke={stroke} />
</TimelineToggle>
</>
);
case 'updated':
if (event.diffs.length === 1) {
return (
<>
<TimelineActor>{event.actor}</TimelineActor>
<TimelineAction>updated</TimelineAction>
<TimelineDiffLabel>{event.diffs[0].label}</TimelineDiffLabel>
<TimelineArrow>→</TimelineArrow>
<TimelineDiffValue value={event.diffs[0].value} />
</>
);
}
return (
<>
<TimelineActor>{event.actor}</TimelineActor>
<TimelineAction>updated</TimelineAction>
<TimelineSubject>
{event.diffs.length} fields on {event.record}
</TimelineSubject>
<TimelineToggle>
<IconChevronUp size={12} stroke={stroke} />
</TimelineToggle>
</>
);
default:
return null;
}
}
function TimelineEventCard({ event }: { event: TimelineEvent }) {
if (event.kind === 'updated' && event.diffs.length > 1) {
return (
<TimelineCardOuter>
<TimelineCardInner>
{event.diffs.map((diff) => (
<TimelineDiffRow key={diff.label}>
<TimelineDiffLabel>{diff.label}</TimelineDiffLabel>
<TimelineArrow>→</TimelineArrow>
<TimelineDiffValue value={diff.value} />
</TimelineDiffRow>
))}
</TimelineCardInner>
</TimelineCardOuter>
);
}
if (event.kind === 'calendar') {
return (
<TimelineCardOuter>
<TimelineCardInner>
<TimelineCardTitle>{event.title}</TimelineCardTitle>
<TimelineCardText>{event.detail}</TimelineCardText>
</TimelineCardInner>
</TimelineCardOuter>
);
}
return null;
}
export function RecordTimeline({ timeline }: { timeline: TimelineEvent[] }) {
return (
<TimelineFeed>
<MonthSeparator>
Today
<MonthSeparatorLine />
</MonthSeparator>
<TimelineGroup>
<TimelineRail />
{timeline.map((event, index) => (
<TimelineRow $index={index} key={event.id}>
<TimelineGutter>
<TimelineIconBox>
<TimelineEventIcon kind={event.kind} />
</TimelineIconBox>
<TimelineConnector $hidden={index === timeline.length - 1} />
</TimelineGutter>
<TimelineMain>
<TimelineSummary>
<TimelineSummaryLeft>
<TimelineEventSummary event={event} />
</TimelineSummaryLeft>
<TimelineTime>{event.time}</TimelineTime>
</TimelineSummary>
<TimelineEventCard event={event} />
</TimelineMain>
</TimelineRow>
))}
</TimelineGroup>
</TimelineFeed>
);
}

View File

@@ -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([]);
});
});

View File

@@ -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<RecordTabLabel, boolean> = {
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,
};

View File

@@ -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: var(--font-product), sans-serif;
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 (
<HoverActions $visible={visible}>
<MiniAction aria-hidden>
<MiniIcon icon={icon} color={THEME_LIGHT.font.color.secondary} />
</MiniAction>
</HoverActions>
);
}
function PersonTokenCell({
isFirstColumn = false,
token,
hovered = false,
variant = 'highlighted',
}: {
hovered?: boolean;
isFirstColumn?: boolean;
token: CellPerson;
variant?: ChipVariant;
}) {
const content = (
<Chip
clickable={false}
label={token.name}
leftComponent={<PersonAvatar person={token} />}
variant={variant}
/>
);
if (isFirstColumn) {
return (
<FirstColumnCellLayout>
<TableCheckbox />
{content}
<HoverAction icon={IconCopy} visible={hovered} />
</FirstColumnCellLayout>
);
}
return (
<CellHoverAnchor>
{content}
<HoverAction icon={IconCopy} visible={hovered} />
</CellHoverAnchor>
);
}
function EntityCellComponent({
cell,
hovered,
isFirstColumn,
}: {
cell: CellEntity;
hovered: boolean;
isFirstColumn: boolean;
}) {
const content = (
<Chip
clickable={false}
label={cell.name}
leftComponent={<FaviconLogo domain={cell.domain} label={cell.name} />}
variant="highlighted"
/>
);
if (isFirstColumn) {
return (
<FirstColumnCellLayout>
<TableCheckbox />
{content}
<HoverAction icon={IconPencil} visible={hovered} />
</FirstColumnCellLayout>
);
}
return (
<CellHoverAnchor>
{content}
<HoverAction icon={IconPencil} visible={hovered} />
</CellHoverAnchor>
);
}
function RelationCellComponent({ cell }: { cell: CellRelation }) {
return (
<CellHoverAnchor>
<MultiChipStack>
{cell.items.map((item) => (
<Chip
key={item.name}
clickable={false}
label={item.name}
leftComponent={
<PreviewAvatar tone={item.tone}>
{item.shortLabel ?? getInitials(item.name)}
</PreviewAvatar>
}
variant="highlighted"
/>
))}
</MultiChipStack>
</CellHoverAnchor>
);
}
function TextCellComponent({
cell,
isFirstColumn,
}: {
cell: CellText;
isFirstColumn: boolean;
}) {
if (!isFirstColumn) {
return <InlineText>{cell.value}</InlineText>;
}
const content = cell.shortLabel ? (
<Chip
clickable={false}
label={cell.value}
leftComponent={
<PreviewAvatar tone={cell.tone}>{cell.shortLabel}</PreviewAvatar>
}
variant="highlighted"
/>
) : (
<Chip clickable={false} label={cell.value} variant="highlighted" />
);
return (
<FirstColumnCellLayout>
<TableCheckbox />
{content}
</FirstColumnCellLayout>
);
}
function LinkCellComponent({ cell }: { cell: CellLink }) {
const label =
cell.label ??
(cell.kind === 'social' && cell.value.startsWith('@')
? cell.value
: cell.value);
return (
<CellHoverAnchor>
<PreviewRoundedLink label={label} />
</CellHoverAnchor>
);
}
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 <TextCellComponent cell={cell} isFirstColumn={isFirstColumn} />;
case 'number':
return <RightAlignedText>{cell.value}</RightAlignedText>;
case 'currency':
return <RightAlignedText>{cell.value}</RightAlignedText>;
case 'link':
return <LinkCellComponent cell={cell} />;
case 'boolean':
return (
<BooleanRow>
{cell.value ? (
<MiniIcon
icon={IconCheck}
color={THEME_LIGHT.font.color.primary}
size={11}
/>
) : (
<MiniIcon
icon={IconX}
color={THEME_LIGHT.font.color.primary}
size={11}
/>
)}
<InlineText>{cell.value ? 'True' : 'False'}</InlineText>
</BooleanRow>
);
case 'select':
return <PreviewTag color={cell.color} label={cell.value} />;
case 'person':
return (
<PersonTokenCell
hovered={hovered && showHoverAction}
isFirstColumn={isFirstColumn}
token={cell}
variant={personChipVariant}
/>
);
case 'entity':
return (
<EntityCellComponent
cell={cell}
hovered={hovered && showHoverAction}
isFirstColumn={isFirstColumn}
/>
);
case 'relation':
return <RelationCellComponent cell={cell} />;
}
}

View File

@@ -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 (
<CheckboxContainer>
<CheckboxBox $checked={checked}>{children}</CheckboxBox>
</CheckboxContainer>
);
}

View File

@@ -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<string, typeof IconCalendarEvent> = {
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 (
<Icon
aria-hidden
color={THEME_LIGHT.font.color.tertiary}
size={16}
stroke={THEME_LIGHT.icon.stroke.sm}
/>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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<HTMLDivElement>();
const [hoveredRowId, setHoveredRowId] = useState<string | null>(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 (
<TableShell>
<TableViewport
$dragging={dragging}
onPointerCancel={onPointerCancel}
onPointerDown={onPointerDown}
onPointerLeave={onPointerLeave}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
ref={viewportRef}
>
<TableCanvas $width={totalTableWidth}>
<HeaderRow>
{page.columns.map((column) => (
<TableCell
$align={column.align}
$header
$sticky={column.isFirstColumn}
$width={column.width}
key={column.id}
>
<HeaderCellContent>
{column.isFirstColumn ? (
<>
<TableCheckbox />
{renderTableHeaderIcon(column.id)}
<HeaderLabel>{column.label}</HeaderLabel>
<EdgePlus aria-hidden>
<MiniIcon
icon={IconPlus}
color={THEME_LIGHT.font.color.tertiary}
size={12}
/>
</EdgePlus>
</>
) : (
<>
{renderTableHeaderIcon(column.id)}
<HeaderLabel>{column.label}</HeaderLabel>
</>
)}
</HeaderCellContent>
</TableCell>
))}
<EmptyFillCell $header $width={fillerWidth}>
{fillerWidth > 0 ? (
<HeaderFillContent>
<MiniIcon
icon={IconPlus}
color={THEME_LIGHT.font.color.tertiary}
size={16}
/>
</HeaderFillContent>
) : null}
</EmptyFillCell>
</HeaderRow>
{page.generating
? SKELETON_ROW_INDEXES.map((rowIndex) => (
<DataRow $rowIndex={rowIndex} key={`skeleton-${rowIndex}`}>
{page.columns.map((column) => (
<TableCell
$align={column.align}
$sticky={column.isFirstColumn}
$width={column.width}
key={column.id}
>
{column.isFirstColumn ? (
<SkeletonRowLead>
<PREVIEW_SKELETON.Circle $size={16} />
<PREVIEW_SKELETON.Bar $height={8} $width="58%" />
</SkeletonRowLead>
) : (
<PREVIEW_SKELETON.Bar $height={8} $width="56%" />
)}
</TableCell>
))}
<EmptyFillCell $width={fillerWidth} />
</DataRow>
))
: page.rows.map((row, rowIndex) => {
const hovered = hoveredRowId === row.id;
return (
<DataRow
$rowIndex={rowIndex}
data-row-id={row.id}
key={row.id}
onMouseEnter={() => setHoveredRowId(row.id)}
onMouseLeave={() =>
setHoveredRowId((current) =>
current === row.id ? null : current,
)
}
>
{page.columns.map((column) => {
const cell = row.cells[column.id];
return (
<TableCell
$align={column.align}
$hovered={hovered}
$sticky={column.isFirstColumn}
$width={column.width}
key={`${row.id}-${column.id}`}
>
{cell
? renderTableCellValue({
cell,
columnId: column.id,
hovered,
isFirstColumn: !!column.isFirstColumn,
})
: null}
</TableCell>
);
})}
<EmptyFillCell $hovered={hovered} $width={fillerWidth} />
</DataRow>
);
})}
<FooterRow>
{page.columns.length > 0 ? (
<TableCell
$sticky={page.columns[0].isFirstColumn}
$width={page.columns[0].width}
>
<FooterFirstContent>
<MutedText>Calculate</MutedText>
<MiniIcon
icon={IconChevronDown}
color={THEME_LIGHT.font.color.tertiary}
/>
</FooterFirstContent>
</TableCell>
) : null}
{page.columns.slice(1).map((column) => (
<TableCell
$align={column.align}
$width={column.width}
key={`footer-${column.id}`}
/>
))}
<EmptyFillCell $footer $width={fillerWidth} />
</FooterRow>
</TableCanvas>
</TableViewport>
</TableShell>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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 (
<BranchLabelPill
$centered={text === 'completed'}
style={{ left: x, top: y }}
>
{text}
</BranchLabelPill>
);
}

View File

@@ -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<WorkflowEdgeDef>;
nodes: ReadonlyArray<PositionedWorkflowNode>;
}) {
const arrowId = `workflow-arrow-${useId().replace(/:/g, '')}`;
const topPortNodeIds = new Set<string>();
const bottomPortNodeIds = new Set<string>();
const rightPortNodeIds = new Set<string>();
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 (
<CanvasOverlay aria-hidden viewBox={`0 0 ${canvasWidth} ${canvasHeight}`}>
<defs>
<marker
id={arrowId}
markerHeight="6"
markerWidth="6"
orient="auto"
refX="5"
refY="3"
>
<path
d="M0.75 0.75 L5 3 L0.75 5.25"
fill="none"
stroke={colors.arrowStroke}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.1"
/>
</marker>
</defs>
{edges.map((edge, index) => (
<EdgePath
d={getWorkflowEdgePath({ edge, nodes })}
fill="none"
key={`${edge.from}-${edge.to}-${edge.type}`}
markerEnd={edge.type === 'loopBack' ? undefined : `url(#${arrowId})`}
pathLength={1}
stroke={colors.arrowStroke}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
style={{ animationDelay: `${140 + index * 150}ms` }}
/>
))}
<PortsLayer>
{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 (
<g key={`ports-${node.id}`}>
{topPortNodeIds.has(node.id) ? (
<circle
cx={cx}
cy={node.y}
fill={colors.nodeSurface}
r="4"
stroke={colors.nodeBorder}
strokeWidth="1"
/>
) : null}
{bottomPortNodeIds.has(node.id) ? (
<circle
cx={cx}
cy={bottomY}
fill={colors.nodeSurface}
r="4"
stroke={colors.nodeBorder}
strokeWidth="1"
/>
) : null}
{rightPortNodeIds.has(node.id) ? (
<circle
cx={rightX}
cy={midY}
fill={colors.nodeSurface}
r="4"
stroke={colors.nodeBorder}
strokeWidth="1"
/>
) : null}
</g>
);
})}
</PortsLayer>
</CanvasOverlay>
);
}

View File

@@ -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<string, typeof IconPlug> = {
clock: IconClock,
code: IconCode,
filter: IconFilter,
mail: IconMail,
plug: IconPlug,
plus: IconPlus,
repeat: IconRepeat,
search: IconSearch,
sitemap: IconSitemap,
};
const NODE_ICON_COLORS: Record<string, string> = {
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: var(--font-product), sans-serif;
font-size: 11px;
font-weight: ${THEME_LIGHT.font.weight.semiBold};
line-height: 1;
`;
const NodeTitle = styled.div`
color: ${colors.textPrimary};
font-family: var(--font-product), sans-serif;
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 (
<Node
$index={index}
style={{ left: node.x, top: node.y, width: node.width }}
>
{generating ? (
<>
<SkeletonIcon />
<NodeContent>
<PREVIEW_SKELETON.Bar $height={8} $width="38%" />
<PREVIEW_SKELETON.Bar $height={9} $width="72%" />
</NodeContent>
</>
) : (
<>
<NodeIconContainer>
<Icon
aria-hidden
color={iconColor}
size={20}
stroke={THEME_LIGHT.icon.stroke.sm}
/>
</NodeIconContainer>
<NodeContent>
<NodeLabel>{node.label}</NodeLabel>
<NodeTitle>{node.title}</NodeTitle>
</NodeContent>
</>
)}
</Node>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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 (
<PageShell>
<CanvasViewportShell>
<ActiveBadge>
<ActiveBadgeLabel>Active</ActiveBadgeLabel>
</ActiveBadge>
<CanvasViewport
aria-label={`Interactive preview of the ${page.header.title.toLowerCase()} workflow`}
>
<Canvas
$height={WORKFLOW_THEME.canvasHeightPx}
$width={WORKFLOW_THEME.canvasWidthPx}
>
<CanvasContent
$height={WORKFLOW_THEME.canvasHeightPx}
$width={WORKFLOW_THEME.canvasWidthPx}
data-workflow-canvas
>
{page.generating ? null : (
<WorkflowEdges
canvasHeight={WORKFLOW_THEME.canvasHeightPx}
canvasWidth={WORKFLOW_THEME.canvasWidthPx}
edges={edges}
nodes={nodes}
/>
)}
{nodes.map((node, index) => (
<WorkflowNode
generating={page.generating}
index={index}
key={node.id}
node={node}
/>
))}
{page.generating
? null
: branchLabels.map((label) => (
<WorkflowBranchLabel
key={`${label.text}-${label.x}`}
text={label.text}
x={label.x}
y={label.y}
/>
))}
</CanvasContent>
</Canvas>
</CanvasViewport>
</CanvasViewportShell>
</PageShell>
);
}

View File

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

View File

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

View File

@@ -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<PositionedWorkflowNode>,
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<PositionedWorkflowNode>;
}): 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}`;
}

View File

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

View File

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

View File

@@ -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: var(--font-product), sans-serif;
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<HTMLDivElement>) => {
if (!isInteractive) {
return;
}
event.stopPropagation();
};
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
if (!isInteractive) {
return;
}
event.stopPropagation();
onClick?.();
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (!onClick) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
}
};
return (
<StyledContainer
$maxWidth={maxWidth}
className={className}
data-bold={isBold ? '' : undefined}
data-clickable={isInteractive ? '' : undefined}
data-variant={variant}
onClick={handleClick}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
>
{leftComponent}
<StyledLabel>{label}</StyledLabel>
</StyledContainer>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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<string | null>(null);
const showFavicon =
faviconUrl !== undefined &&
!failedFaviconUrls.has(faviconUrl) &&
localFailedUrl !== faviconUrl;
if (showFavicon) {
return (
<FaviconFrame $size={size}>
<FaviconImage
alt={label ? `${label} logo` : ''}
// Opts the mockup's chrome out of React 19's SSR image preloading,
// which would otherwise preload third-party favicons in <head>.
fetchPriority="low"
src={faviconUrl}
onError={() => {
failedFaviconUrls.add(faviconUrl);
setLocalFailedUrl(faviconUrl);
}}
/>
</FaviconFrame>
);
}
const initials = label ? getInitials(label) : '?';
return (
<FaviconFallbackFrame $size={size}>
{initials.slice(0, 1)}
</FaviconFallbackFrame>
);
}

View File

@@ -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 <Icon aria-hidden color={color} size={size} stroke={stroke} />;
}

View File

@@ -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<string | null>(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 (
<PreviewAvatar size={size} square={square} tone={person.tone}>
{showAvatar ? (
<AvatarImage
alt=""
// Opts the mockup's chrome out of React 19's SSR image preloading,
// which would otherwise push every avatar into <head> 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())
)}
</PreviewAvatar>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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 (
<AvatarFrame
$size={size}
data-square={square ? '' : undefined}
data-tone={tone}
>
{children}
</AvatarFrame>
);
}

View File

@@ -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<string, typeof IconBuildingSkyscraper> = {
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: var(--font-product), sans-serif;
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 (
<LinkOverlayFrame>
<IconLink
aria-hidden
color={THEME_LIGHT.font.color.secondary}
size={7}
stroke={THEME_LIGHT.icon.stroke.sm}
/>
</LinkOverlayFrame>
);
}
export function renderPreviewIcon(
icon: SidebarIcon,
pulse: boolean = false,
): ReactNode {
if (icon.kind === 'brand') {
return (
<SidebarIconSurface
$background="transparent"
$border="transparent"
$color={THEME_LIGHT.font.color.secondary}
$pulse={pulse}
>
{icon.brand === 'twenty' ? (
// The official mark is a component — never a raster copy.
<TwentyLogo sizePx={14} />
) : (
<FaviconLogo
domain={icon.domain}
label={icon.brand}
size={icon.imageSrc ? 14 : 16}
src={icon.imageSrc}
/>
)}
{icon.overlay === 'link' ? <LinkOverlay /> : null}
</SidebarIconSurface>
);
}
if (icon.kind === 'avatar') {
const tone =
APP_PREVIEW_TONES.sidebar[icon.tone] ?? APP_PREVIEW_TONES.sidebar.gray;
return (
<SidebarAvatar
$background={tone.background}
$color={icon.color ?? tone.color}
$shape={icon.shape}
>
{icon.label}
</SidebarAvatar>
);
}
const tone =
APP_PREVIEW_TONES.sidebar[icon.tone] ?? APP_PREVIEW_TONES.sidebar.gray;
const TablerIcon = TABLER_ICON_MAP[icon.name];
return (
<SidebarIconSurface
$background={tone.background}
$border={tone.border}
$color={tone.color}
$pulse={pulse}
>
{TablerIcon ? (
<TablerIcon
aria-hidden
color={tone.color}
size={14}
stroke={THEME_LIGHT.icon.stroke.md}
/>
) : null}
{icon.overlay === 'link' ? <LinkOverlay /> : null}
</SidebarIconSurface>
);
}

View File

@@ -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: var(--font-product), sans-serif;
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 <LinkPill>{label}</LinkPill>;
}

View File

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

View File

@@ -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: var(--font-product), sans-serif;
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 (
<TagPill
$background={THEME_LIGHT.tag.background[tagColor]}
$color={THEME_LIGHT.tag.text[tagColor]}
>
{label}
</TagPill>
);
}

View File

@@ -0,0 +1,9 @@
export function getInitials(value: string): string {
return value
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
}

View File

@@ -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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
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<HTMLDivElement>(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 (
<PanelShell $panelOnly={panelOnly}>
<PanelHeader>
<HeaderButton>
<IconX size={13} stroke={2} />
</HeaderButton>
<PanelTitle>Ask AI</PanelTitle>
<HeaderButton>
<IconEdit size={13} stroke={2} />
</HeaderButton>
</PanelHeader>
<Messages ref={messagesRef}>
<UserMessage>{scene.label}</UserMessage>
{stepsSlot}
{streamedTextVisibleLength > 0 ? (
<>
<AnswerText>
{revealedParagraphs.map((paragraph) => (
<AnswerParagraph key={paragraph.number}>
{paragraph.segments.map((segment) =>
segment.bold ? (
<AnswerStrong key={segment.number}>
{segment.text}
</AnswerStrong>
) : (
<Fragment key={segment.number}>{segment.text}</Fragment>
),
)}
</AnswerParagraph>
))}
</AnswerText>
{streamComplete && responseChips.length > 0 ? (
<EntityChips>
{visibleResponseChips.map((chip) => (
<EntityChip key={chip.name}>
<EntityChipIcon
alt={chip.name}
fetchPriority="low"
src={chip.logoUrl}
/>
<EntityChipName>{chip.name}</EntityChipName>
</EntityChip>
))}
{hiddenResponseChipCount > 0 ? (
<EntityOverflowChip>
+{hiddenResponseChipCount} more
</EntityOverflowChip>
) : null}
</EntityChips>
) : null}
</>
) : hasSteps ? null : (
<ThinkingText>Thinking...</ThinkingText>
)}
</Messages>
<InputArea>
<InputBox>
<InputPlaceholder>Ask, search or make anything...</InputPlaceholder>
<InputButtonRow>
<InputLeftButtons>
<IconPaperclip size={13} stroke={2} />
</InputLeftButtons>
<InputRightButtons>
<ModelChip>
<ClaudeMark sizePx={12} />
Claude Opus 4.6
<ModelChipChevron>
<IconChevronDown size={13} stroke={2} />
</ModelChipChevron>
</ModelChip>
<SendButton>
<IconArrowUp size={13} stroke={2} />
</SendButton>
</InputRightButtons>
</InputButtonRow>
</InputBox>
</InputArea>
</PanelShell>
);
}

View File

@@ -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<AgentToolIcon, typeof IconSearch> = {
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: var(--font-product), sans-serif;
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: var(--font-product), sans-serif;
font-size: 12px;
font-weight: 400;
line-height: 18px;
`;
function ThinkingOrbitLoader() {
return (
<StepLoaderIcon>
<svg
aria-hidden="true"
focusable="false"
height="14"
viewBox="0 0 14 14"
width="14"
>
<path
d="M3.1 7 C3.1 4.4 6.0 4.4 7.0 7 C8.0 9.6 10.9 9.6 10.9 7 C10.9 4.4 8.0 4.4 7.0 7 C6.0 9.6 3.1 9.6 3.1 7"
fill="none"
pathLength={100}
shapeRendering="geometricPrecision"
stroke="currentColor"
strokeDasharray="14 86"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.4"
>
<animate
attributeName="stroke-dashoffset"
dur="1.05s"
repeatCount="indefinite"
values="0;-100"
/>
<animate
attributeName="stroke-dasharray"
dur="1.05s"
repeatCount="indefinite"
values="10 90;16 84;10 90"
/>
</path>
</svg>
</StepLoaderIcon>
);
}
// 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 (
<StepsContainer>
{shouldShowSummaryButton ? (
<SummaryButton
aria-expanded={isExpanded}
type="button"
onClick={() => setIsExpanded((previousValue) => !previousValue)}
>
<SummaryChevron
style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
>
<IconChevronRight size={14} stroke={2} />
</SummaryChevron>
<SummaryText>
{stepCount === 1 ? '1 step' : `${stepCount} steps`}
</SummaryText>
</SummaryButton>
) : null}
{shouldRenderRows ? (
<StepList>
{visibleSteps.map(({ step, stepNumber }) => {
const isRunning = stepNumber === activeStepIndex;
if (step.kind === 'thinking') {
return (
<StepRow key={`thinking-${stepNumber}`}>
{isRunning ? (
<ThinkingOrbitLoader />
) : (
<StepIcon>
<IconCpu size={14} stroke={2} />
</StepIcon>
)}
<StepLabel>{isRunning ? 'Thinking' : 'Thought'}</StepLabel>
</StepRow>
);
}
const ToolIcon = TOOL_ICONS[step.icon];
return (
<StepRow key={`tool-${stepNumber}`}>
<StepIcon>
<ToolIcon size={14} stroke={1.8} />
</StepIcon>
<StepLabel>{isRunning ? step.running : step.done}</StepLabel>
</StepRow>
);
})}
</StepList>
) : null}
</StepsContainer>
);
}

View File

@@ -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<HTMLDivElement>(null);
const [coordinate, setCoordinate] = useState<HeroCursorCoordinate>(home);
const [glideMs, setGlideMs] = useState(0);
const coordinateRef = useRef<HeroCursorCoordinate>(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 (
<Overlay aria-hidden="true" ref={overlayRef}>
<Marker
$clicking={clicking}
$glideMs={glideMs}
$hidden={hidden}
$left={coordinate.left}
$top={coordinate.top}
>
<MarkerCursor color={color} rotation={132} />
<Label $color={color}>{name}</Label>
</Marker>
</Overlay>
);
}

View File

@@ -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 : (
<AiPanel
activeStepIndex={activeStepIndex}
completedStepCount={completedStepCount}
panelOnly={panelOnly}
scene={selectedScene}
stepsSlot={
agentSteps.length > 0 ? (
<AiSteps
activeStepIndex={activeStepIndex}
answerStarted={streamedTextVisibleLength > 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 (
<ProductHeroCursor
clicking={isActive && heroCursor.clicking}
color={cursorConfig.color}
glideMs={isActive ? heroCursor.glideMs : undefined}
hidden={isActive && heroCursor.hidden}
home={
compactCursorTour && cursorConfig.mobileHome
? cursorConfig.mobileHome
: cursorConfig.home
}
key={cursorConfig.name}
name={cursorConfig.name}
target={isActive ? heroCursor.target : undefined}
/>
);
}),
cursorLayer,
)
: null;
return (
<VisualRoot $fill={fill}>
<ShellScene $presentation={presentation}>
<ProductFrame compact={presentation !== 'bleed'} floatingShadow>
<PreviewAppLayout
activeItem={activeItem}
activeItemId={activeItemId}
compactWorkflowPage={compactWorkflowPage}
desktopSidebarMode={desktopSidebarMode}
favorites={APP_PREVIEW_CONFIG.sidebar.favorites}
highlightedItemId={highlightedItemId}
navbarActions={effectivePage.header.navbarActions}
navbarLabel={navbarLabel}
onSelectPageItem={selectPageItem}
onToggleFolder={toggleFolder}
openFolderIds={openFolderIds}
page={effectivePage}
panelOnly={panelOnly}
revealedObjectIds={revealedObjectIds}
rightAside={rightAside}
workspaceEntries={sidebarEntries}
/>
</ProductFrame>
</ShellScene>
{cursors}
</VisualRoot>
);
}

View File

@@ -0,0 +1,282 @@
import { sharedAssetUrls } from '../data/shared-asset-urls';
import { type RecordPageDefinition } from '../types';
const avatars = sharedAssetUrls.peopleAvatars;
// The company record the collaborative cursor tour opens and reads.
export const ANTHROPIC_RECORD_PAGE: RecordPageDefinition = {
type: 'record',
header: {
title: 'Anthropic',
count: 12,
},
record: {
logoDomain: 'anthropic.com',
name: 'Anthropic',
createdAt: 'Created 4 hours ago',
fields: [
{
icon: 'link',
label: 'URL',
value: { type: 'link', kind: 'url', value: 'anthropic.com' },
},
{
icon: 'user',
label: 'Account Owner',
value: {
type: 'person',
name: 'Dario Amodei',
avatarUrl: avatars.darioAmodei,
kind: 'person',
tone: 'gray',
},
},
{
icon: 'mapPin',
label: 'Address',
value: { type: 'text', value: '18 Rue De Navarin' },
},
{
icon: 'check',
label: 'ICP',
value: { type: 'boolean', value: true },
},
{
icon: 'currency',
label: 'ARR',
value: { type: 'currency', value: '$500,000' },
},
{
icon: 'linkedin',
label: 'LinkedIn',
value: {
type: 'link',
kind: 'social',
label: 'linkedin.com/company/a...',
value: 'anthropic',
},
},
],
moreCount: 12,
relations: [
{
title: 'Opportunities',
items: [{ name: 'Enterprise Expansion', domain: 'anthropic.com' }],
},
{
title: 'People',
count: 12,
items: [
{
name: 'Dario Amodei',
avatarUrl: avatars.darioAmodei,
},
],
},
],
},
notes: [
{
id: 'kickoff',
title: 'Kickoff with Dario',
body: 'Walked through the enterprise expansion plan and the security review timeline. Anthropic wants SSO, audit logs, and a dedicated environment before rolling out to the wider research org.',
relation: {
name: 'Dario Amodei',
avatarUrl: avatars.darioAmodei,
},
},
{
id: 'pricing-follow-up',
title: 'Follow-up on pricing',
body: 'Shared updated seat pricing and the annual commitment options. Next step is a procurement intro and a technical deep-dive with their platform team.',
},
],
timeline: [
{
kind: 'calendar',
id: 'calendar-security-review',
actor: 'Alice',
title: 'Security review',
detail: 'Tomorrow · 10:00 – 10:45 AM · Dario Amodei, Alice',
time: '1 hour ago',
},
{
kind: 'note',
id: 'note-kickoff',
actor: 'Alice',
title: 'Kickoff with Dario',
time: '2 hours ago',
},
{
kind: 'updated',
id: 'update-multi',
actor: 'Alice',
record: 'Anthropic',
time: '3 hours ago',
diffs: [
{ label: 'Industry', value: { type: 'select', value: 'AI Research' } },
{ label: 'Employees', value: { type: 'text', value: '612' } },
{ label: 'ICP', value: { type: 'boolean', value: true } },
],
},
{
kind: 'updated',
id: 'update-arr',
actor: 'Alice',
record: 'Anthropic',
time: '3 hours ago',
diffs: [{ label: 'ARR', value: { type: 'currency', value: '$500,000' } }],
},
{
kind: 'created',
id: 'record-created',
subject: 'Anthropic',
actor: 'Dario Amodei',
time: '4 hours ago',
},
],
tasks: [
{
id: 'task-agreement',
title: 'Send enterprise agreement',
body: 'Final redlines from legal',
due: 'Tomorrow',
target: {
name: 'Dario Amodei',
avatarUrl: avatars.darioAmodei,
tone: 'gray',
},
},
{
id: 'task-security',
title: 'Complete security review',
body: 'SOC 2 and DPA sign-off',
due: 'Friday',
done: true,
target: { name: 'Enterprise Expansion', domain: 'anthropic.com' },
},
{
id: 'task-procurement',
title: 'Schedule procurement intro',
body: 'Loop in their procurement lead',
due: 'Next week',
target: { name: 'Alice', tone: 'amber' },
},
],
files: [
{
id: 'file-agreement',
name: 'Enterprise Agreement.pdf',
category: 'pdf',
date: '2 hours ago',
},
{
id: 'file-security',
name: 'Security Review.xlsx',
category: 'sheet',
date: '1 day ago',
},
{
id: 'file-pricing',
name: 'Pricing Proposal.pdf',
category: 'pdf',
date: '3 days ago',
},
],
emails: [
{
id: 'email-expansion',
participants: [
{
name: 'Dario Amodei',
avatarUrl: avatars.darioAmodei,
tone: 'gray',
},
{ name: 'Alice', tone: 'amber' },
],
count: 3,
subject: 'Re: Enterprise expansion',
body: 'Thanks for the detailed proposal — looping in our platform team.',
date: '2 hours ago',
},
{
id: 'email-compliance',
participants: [
{ name: 'Alice', tone: 'amber' },
{
name: 'Dario Amodei',
avatarUrl: avatars.darioAmodei,
tone: 'gray',
},
],
count: 2,
subject: 'Security & compliance docs',
body: 'Attaching the SOC 2 report and our DPA for review.',
date: '1 day ago',
},
{
id: 'email-procurement',
participants: [
{
name: 'Dario Amodei',
avatarUrl: avatars.darioAmodei,
tone: 'gray',
},
],
count: 1,
subject: 'Intro to procurement',
body: 'Connecting you with our procurement lead to move the contract forward.',
date: '2 days ago',
},
],
calendar: [
{
id: 'cal-day-wed',
weekday: 'Wed',
day: '12',
events: [
{
id: 'cal-security',
start: '10:00',
end: '10:45',
title: 'Security review',
attending: true,
participants: [
{
name: 'Dario Amodei',
avatarUrl: avatars.darioAmodei,
tone: 'gray',
},
{ name: 'Alice', tone: 'amber' },
],
},
],
},
{
id: 'cal-day-fri',
weekday: 'Fri',
day: '14',
events: [
{
id: 'cal-qbr',
start: '14:00',
end: '15:00',
title: 'Quarterly business review',
participants: [
{
name: 'Dario Amodei',
avatarUrl: avatars.darioAmodei,
tone: 'gray',
},
{ name: 'Alice', tone: 'amber' },
{
name: 'Marcus Lee',
avatarUrl: avatars.anonymousMike,
tone: 'amber',
},
],
},
],
},
],
};

View File

@@ -0,0 +1,33 @@
import { type HeroCursorCoordinate } from './hero-cursors';
const GLIDE_BASE_MS = 500;
const GLIDE_MS_PER_PX = 0.55;
const GLIDE_MIN_MS = 620;
const GLIDE_MAX_MS = 1000;
const GLIDE_SKIP_PX = 6;
// Percent coordinates resolve against the overlay box.
function pixelDistance(
from: HeroCursorCoordinate,
to: HeroCursorCoordinate,
overlay: { height: number; width: number },
): number {
const dx = ((to.left - from.left) / 100) * overlay.width;
const dy = ((to.top - from.top) / 100) * overlay.height;
return Math.hypot(dx, dy);
}
// Longer hops glide longer, clamped to a believable hand speed.
function forDistance(distance: number): number {
return Math.max(
GLIDE_MIN_MS,
Math.min(GLIDE_MAX_MS, GLIDE_BASE_MS + distance * GLIDE_MS_PER_PX),
);
}
export const cursorGlide = {
SKIP_PX: GLIDE_SKIP_PX,
forDistance,
pixelDistance,
};

View File

@@ -0,0 +1,71 @@
import { cursorTour } from './cursor-tour-phases';
import { cursorGlide } from './cursor-glide';
describe('cursorTour.nextPhaseIndex', () => {
it('should advance through the tour', () => {
expect(cursorTour.nextPhaseIndex(0, 17)).toBe(1);
expect(cursorTour.nextPhaseIndex(15, 17)).toBe(16);
});
it('should loop back to the start after the last phase', () => {
expect(cursorTour.nextPhaseIndex(16, 17)).toBe(0);
expect(cursorTour.nextPhaseIndex(8, 9)).toBe(0);
});
});
describe('cursorTour phase tables', () => {
it('should keep the desktop tour at 17 phases and mobile at 9', () => {
expect(cursorTour.DESKTOP_PHASES).toHaveLength(17);
expect(cursorTour.MOBILE_PHASES).toHaveLength(9);
});
it('should only target record tabs that exist', () => {
const tabTargets = [
...cursorTour.DESKTOP_PHASES,
...cursorTour.MOBILE_PHASES,
]
.map((phase) => phase.target)
.filter((target) => target.kind === 'tab')
.map((target) => target.id);
const validTabs = new Set([
'Timeline',
'Tasks',
'Notes',
'Files',
'Emails',
'Calendar',
]);
expect(tabTargets.length).toBeGreaterThan(0);
for (const id of tabTargets) {
expect(validTabs.has(id)).toBe(true);
}
});
it('should alternate the three cursors on mobile', () => {
expect(cursorTour.MOBILE_PHASES.map((phase) => phase.activeCursor)).toEqual(
[0, 0, 0, 1, 1, 1, 2, 2, 2],
);
});
});
describe('cursorGlide', () => {
it('should clamp the glide duration to the authored hand speed', () => {
expect(cursorGlide.forDistance(0)).toBe(620);
expect(cursorGlide.forDistance(400)).toBe(720);
expect(cursorGlide.forDistance(2000)).toBe(1000);
});
it('should measure percent distances against the overlay box', () => {
expect(
cursorGlide.pixelDistance(
{ left: 0, top: 0 },
{ left: 50, top: 0 },
{ width: 1000, height: 600 },
),
).toBe(500);
});
it('should skip sub-threshold hops', () => {
expect(cursorGlide.SKIP_PX).toBe(6);
});
});

View File

@@ -0,0 +1,291 @@
import { PAGE_ITEM_IDS } from './page-item-ids';
export type CursorTarget =
| { kind: 'home' }
| { kind: 'row'; id: string }
| { kind: 'rail'; id: string }
| { kind: 'tab'; id: string };
export type TourPhase = {
activeCursor: number;
clicking: boolean;
durationMs: number;
glideMs?: number;
hidden: boolean;
pageItemId: string;
recordTab?: string;
showRecord: boolean;
target: CursorTarget;
};
const COMPANIES = PAGE_ITEM_IDS.companies;
const PEOPLE = PAGE_ITEM_IDS.people;
const NOTES = 'notes';
const RECORD_ROW_ID = 'anthropic';
const LOOP_START_INDEX = 0;
// Desktop tour — explores the record's Timeline/Notes/Calendar tabs.
const DESKTOP_PHASES: TourPhase[] = [
{
activeCursor: 0,
pageItemId: COMPANIES,
showRecord: false,
target: { kind: 'home' },
clicking: false,
hidden: false,
durationMs: 1400,
},
{
activeCursor: 0,
pageItemId: COMPANIES,
showRecord: false,
target: { kind: 'row', id: RECORD_ROW_ID },
clicking: false,
hidden: false,
durationMs: 1000,
},
{
activeCursor: 0,
pageItemId: COMPANIES,
showRecord: false,
target: { kind: 'row', id: RECORD_ROW_ID },
clicking: true,
hidden: false,
durationMs: 400,
},
{
activeCursor: 1,
pageItemId: COMPANIES,
showRecord: true,
recordTab: 'Timeline',
target: { kind: 'home' },
clicking: false,
hidden: false,
durationMs: 800,
},
{
activeCursor: 1,
pageItemId: COMPANIES,
showRecord: true,
recordTab: 'Timeline',
target: { kind: 'tab', id: 'Notes' },
clicking: false,
hidden: false,
durationMs: 900,
},
{
activeCursor: 1,
pageItemId: COMPANIES,
showRecord: true,
recordTab: 'Notes',
target: { kind: 'tab', id: 'Notes' },
clicking: true,
hidden: false,
durationMs: 450,
},
{
activeCursor: 2,
pageItemId: COMPANIES,
showRecord: true,
recordTab: 'Notes',
target: { kind: 'home' },
clicking: false,
hidden: false,
durationMs: 800,
},
{
activeCursor: 2,
pageItemId: COMPANIES,
showRecord: true,
recordTab: 'Notes',
target: { kind: 'tab', id: 'Calendar' },
clicking: false,
hidden: false,
durationMs: 750,
},
{
activeCursor: 2,
pageItemId: COMPANIES,
showRecord: true,
recordTab: 'Calendar',
target: { kind: 'tab', id: 'Calendar' },
clicking: true,
hidden: false,
durationMs: 700,
},
{
activeCursor: 0,
pageItemId: COMPANIES,
showRecord: true,
recordTab: 'Calendar',
target: { kind: 'rail', id: PEOPLE },
clicking: false,
hidden: false,
durationMs: 700,
},
{
activeCursor: 0,
pageItemId: PEOPLE,
showRecord: true,
recordTab: 'Calendar',
target: { kind: 'rail', id: PEOPLE },
clicking: true,
hidden: false,
durationMs: 300,
},
{
activeCursor: 0,
pageItemId: PEOPLE,
showRecord: false,
target: { kind: 'rail', id: PEOPLE },
clicking: false,
hidden: false,
durationMs: 900,
},
{
activeCursor: 1,
pageItemId: PEOPLE,
showRecord: false,
target: { kind: 'rail', id: NOTES },
clicking: false,
hidden: false,
durationMs: 800,
},
{
activeCursor: 1,
pageItemId: NOTES,
showRecord: false,
target: { kind: 'rail', id: NOTES },
clicking: true,
hidden: false,
durationMs: 300,
},
{
activeCursor: 1,
pageItemId: NOTES,
showRecord: false,
target: { kind: 'rail', id: NOTES },
clicking: false,
hidden: false,
durationMs: 600,
},
{
activeCursor: 2,
pageItemId: NOTES,
showRecord: false,
target: { kind: 'rail', id: COMPANIES },
clicking: false,
hidden: false,
durationMs: 1100,
},
{
activeCursor: 2,
pageItemId: COMPANIES,
showRecord: false,
target: { kind: 'rail', id: COMPANIES },
clicking: true,
hidden: false,
durationMs: 300,
},
];
// Mobile tour — the three cursors strictly alternate (Alice -> Ben ->
// Cara) and only touch elements that fit a narrow window: the Companies
// list, the record Timeline tab, and the sidebar rails.
const MOBILE_PHASES: TourPhase[] = [
{
activeCursor: 0,
pageItemId: COMPANIES,
showRecord: false,
target: { kind: 'home' },
clicking: false,
hidden: false,
durationMs: 1400,
},
{
activeCursor: 0,
pageItemId: COMPANIES,
showRecord: false,
target: { kind: 'row', id: RECORD_ROW_ID },
clicking: false,
hidden: false,
durationMs: 1000,
},
{
activeCursor: 0,
pageItemId: COMPANIES,
showRecord: false,
target: { kind: 'row', id: RECORD_ROW_ID },
clicking: true,
hidden: false,
durationMs: 400,
},
{
activeCursor: 1,
pageItemId: COMPANIES,
showRecord: true,
recordTab: 'Timeline',
target: { kind: 'home' },
clicking: false,
hidden: false,
durationMs: 1000,
},
{
activeCursor: 1,
pageItemId: COMPANIES,
showRecord: true,
recordTab: 'Timeline',
target: { kind: 'rail', id: PEOPLE },
clicking: false,
hidden: false,
durationMs: 900,
},
{
activeCursor: 1,
pageItemId: PEOPLE,
showRecord: false,
target: { kind: 'rail', id: PEOPLE },
clicking: true,
hidden: false,
durationMs: 400,
},
{
activeCursor: 2,
pageItemId: PEOPLE,
showRecord: false,
target: { kind: 'home' },
clicking: false,
hidden: false,
durationMs: 1000,
},
{
activeCursor: 2,
pageItemId: PEOPLE,
showRecord: false,
target: { kind: 'rail', id: COMPANIES },
clicking: false,
hidden: false,
durationMs: 900,
},
{
activeCursor: 2,
pageItemId: COMPANIES,
showRecord: false,
target: { kind: 'rail', id: COMPANIES },
clicking: true,
hidden: false,
durationMs: 400,
},
];
// The tour replays from the top once the last phase's dwell elapses.
function nextPhaseIndex(current: number, phaseCount: number): number {
return current >= phaseCount - 1 ? LOOP_START_INDEX : current + 1;
}
export const cursorTour = {
DESKTOP_PHASES,
MOBILE_PHASES,
nextPhaseIndex,
};

View File

@@ -0,0 +1,114 @@
import { displayPage } from './display-page';
import { PRODUCT_VISUAL_SCENES } from './product-visual-scenes';
import { NEW_TASK_ROWS } from './new-task-rows';
import { type KanbanPageDefinition, type TablePageDefinition } from '../types';
const { buildFocusedOpportunitiesPage, getStreamProgress, resolveDisplayPage } =
displayPage;
const sceneByKind = (kind: string) => {
const scene = PRODUCT_VISUAL_SCENES.find((entry) => entry.kind === kind);
if (!scene) throw new Error(`missing scene ${kind}`);
return scene;
};
const kanbanPage: KanbanPageDefinition = {
type: 'kanban',
header: { title: 'All Opportunities', count: 9 },
lanes: [
{
id: 'new',
label: 'New',
tone: 'blue',
cards: [
{ id: 'a' },
{ id: 'b' },
] as KanbanPageDefinition['lanes'][number]['cards'],
},
{
id: 'empty',
label: 'Screening',
tone: 'gray',
cards: [],
},
{
id: 'meeting',
label: 'Meeting',
tone: 'green',
cards: [{ id: 'c' }] as KanbanPageDefinition['lanes'][number]['cards'],
},
],
};
describe('displayPage.getStreamProgress', () => {
it('should treat an empty answer as fully streamed', () => {
expect(getStreamProgress(0, 0)).toBe(1);
});
it('should clamp to 1', () => {
expect(getStreamProgress(50, 10)).toBe(1);
});
});
describe('displayPage.buildFocusedOpportunitiesPage', () => {
it('should drop empty lanes and retitle the board', () => {
const focused = buildFocusedOpportunitiesPage(kanbanPage, 1);
expect(focused.lanes.map((lane) => lane.id)).toEqual(['new', 'meeting']);
expect(focused.header.title).toBe('Pipeline by stage');
expect(focused.header.count).toBe(3);
});
it('should reveal no cards before 18% stream progress', () => {
const focused = buildFocusedOpportunitiesPage(kanbanPage, 0.18);
expect(focused.lanes.every((lane) => lane.cards.length === 0)).toBe(true);
expect(focused.header.count).toBe(0);
});
it('should fill lanes left to right as progress advances', () => {
const focused = buildFocusedOpportunitiesPage(kanbanPage, 0.5);
const counts = focused.lanes.map((lane) => lane.cards.length);
expect(counts[0]).toBeGreaterThan(0);
expect(counts.reduce((a, b) => a + b, 0)).toBe(focused.header.count);
});
});
describe('displayPage.resolveDisplayPage', () => {
it('should mark the kanban generating at zero progress and hide the count', () => {
const scene = sceneByKind('opportunityReview');
const resolved = resolveDisplayPage(kanbanPage, 'opportunities', scene, 0);
expect(resolved.type).toBe('kanban');
if (resolved.type === 'kanban') {
expect(resolved.generating).toBe(true);
expect(resolved.header.count).toBeUndefined();
expect(resolved.lanes.map((lane) => lane.id)).toEqual(['new', 'meeting']);
}
});
it('should swap the tasks table to the generated rows', () => {
const scene = sceneByKind('taskCreation');
const tablePage: TablePageDefinition = {
type: 'table',
header: { title: 'All Tasks', count: 3 },
columns: [],
rows: [],
};
const generating = resolveDisplayPage(tablePage, 'tasks', scene, 0);
if (generating.type === 'table') {
expect(generating.generating).toBe(true);
expect(generating.header.count).toBeUndefined();
}
const streamed = resolveDisplayPage(tablePage, 'tasks', scene, 0.4);
if (streamed.type === 'table') {
expect(streamed.generating).toBe(false);
expect(streamed.rows).toBe(NEW_TASK_ROWS);
expect(streamed.header.count).toBe(10);
}
});
it('should pass through pages the scene does not touch', () => {
const scene = sceneByKind('leadCreation');
expect(resolveDisplayPage(kanbanPage, 'companies', scene, 1)).toBe(
kanbanPage,
);
});
});

View File

@@ -0,0 +1,126 @@
import { clampProgress } from '@/platform/motion';
import { type KanbanPageDefinition, type PageDefinition } from '../types';
import { NEW_TASK_ROWS } from './new-task-rows';
import { type ProductVisualSceneDefinition } from './product-visual-scenes';
// An empty answer counts as fully streamed: scenes without responseText
// (the intro) must never sit in the "generating" state.
function getStreamProgress(streamedLength: number, fullTextLength: number) {
if (fullTextLength === 0) {
return 1;
}
return Math.min(streamedLength / fullTextLength, 1);
}
// Cards "arrive" while the answer streams: reveal runs over the 18%–80%
// stretch of stream progress, filling lanes left to right.
function buildFocusedOpportunitiesPage(
page: KanbanPageDefinition,
streamProgress: number,
): KanbanPageDefinition {
const filteredLanes = page.lanes.filter((lane) => lane.cards.length > 0);
const revealProgress = clampProgress((streamProgress - 0.18) / 0.62);
const totalCardCount = filteredLanes.reduce(
(total, lane) => total + lane.cards.length,
0,
);
const visibleCardCount =
revealProgress <= 0
? 0
: Math.min(totalCardCount, Math.ceil(revealProgress * totalCardCount));
let remainingVisibleCards = visibleCardCount;
const lanes = filteredLanes.map((lane) => {
const laneVisibleCount = Math.max(
0,
Math.min(lane.cards.length, remainingVisibleCards),
);
remainingVisibleCards -= laneVisibleCount;
return {
cards: lane.cards.slice(0, laneVisibleCount),
id: lane.id,
label: lane.label,
tone: lane.tone,
};
});
return {
...page,
header: {
...page.header,
count: visibleCardCount,
title: 'Pipeline by stage',
},
lanes,
};
}
// The page each AI scene presents while its answer streams: generating
// skeletons at 0% progress, then the scene's data.
function resolveDisplayPage(
activePage: PageDefinition,
activeItemId: string,
scene: ProductVisualSceneDefinition,
streamProgress: number,
): PageDefinition {
if (scene.kind === 'opportunityReview' && activePage.type === 'kanban') {
const generating = streamProgress === 0;
const focusedPage = buildFocusedOpportunitiesPage(activePage, 1);
return {
...focusedPage,
generating,
header: {
...focusedPage.header,
count: generating ? undefined : focusedPage.header.count,
},
};
}
if (scene.kind === 'dashboardCreation' && activePage.type === 'dashboard') {
return {
...activePage,
dashboard: {
...activePage.dashboard,
generating: streamProgress === 0,
},
};
}
if (
scene.kind === 'taskCreation' &&
activeItemId === scene.initialPageItemId &&
activePage.type === 'table'
) {
const generating = streamProgress === 0;
return {
...activePage,
generating,
header: {
...activePage.header,
count: generating ? undefined : NEW_TASK_ROWS.length,
},
rows: NEW_TASK_ROWS,
};
}
if (scene.kind === 'workflowCreation' && activePage.type === 'workflow') {
return {
...activePage,
generating: streamProgress === 0,
};
}
return activePage;
}
export const displayPage = {
buildFocusedOpportunitiesPage,
getStreamProgress,
resolveDisplayPage,
};

View File

@@ -0,0 +1,26 @@
import { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones';
export type HeroCursorCoordinate = { left: number; top: number };
type HeroCursorConfig = {
color: string;
home: HeroCursorCoordinate;
// Resting position on the phone bleed layout, where the window runs
// off the right edge; defaults to home when unset.
mobileHome?: HeroCursorCoordinate;
name: string;
};
const inks = APP_PREVIEW_TONES.productVisual.heroCursorInks;
// The three collaborators touring the record (authored colors/positions).
export const HERO_CURSORS: HeroCursorConfig[] = [
{
name: 'Alice',
color: inks.alice,
home: { left: 13, top: 34 },
mobileHome: { left: 13, top: -6 },
},
{ name: 'Ben', color: inks.ben, home: { left: 36, top: 90 } },
{ name: 'Cara', color: inks.cara, home: { left: 90, top: 51 } },
];

View File

@@ -0,0 +1,259 @@
import { sharedAssetUrls } from '../data/shared-asset-urls';
import { type RowDef } from '../types';
const avatars = sharedAssetUrls.peopleAvatars;
// The 10 follow-up tasks the taskCreation scene "creates" while its
// answer streams in.
export const NEW_TASK_ROWS: RowDef[] = [
{
id: 'task-follow-up-anthropic',
cells: {
title: {
type: 'text',
value: 'Follow up on Enterprise Expansion',
shortLabel: 'F',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Dario Amodei',
tone: 'gray',
kind: 'person',
avatarUrl: avatars.darioAmodei,
},
dueDate: { type: 'text', value: 'Nov 1, 2023' },
relatedTo: {
type: 'entity',
name: 'Anthropic',
domain: 'anthropic.com',
},
status: { type: 'select', value: 'To Do' },
},
},
{
id: 'task-follow-up-slack',
cells: {
title: {
type: 'text',
value: 'Schedule renewal call',
shortLabel: 'S',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Stewart Butterfield',
tone: 'teal',
kind: 'person',
avatarUrl: avatars.stewartButterfield,
},
dueDate: { type: 'text', value: 'Nov 2, 2023' },
relatedTo: {
type: 'entity',
name: 'Slack',
domain: 'slack.com',
},
status: { type: 'select', value: 'To Do' },
},
},
{
id: 'task-follow-up-figma',
cells: {
title: {
type: 'text',
value: 'Send proposal to Dylan',
shortLabel: 'S',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Dylan Field',
tone: 'purple',
kind: 'person',
avatarUrl: avatars.dylanField,
},
dueDate: { type: 'text', value: 'Nov 3, 2023' },
relatedTo: {
type: 'entity',
name: 'Figma',
domain: 'figma.com',
},
status: { type: 'select', value: 'To Do' },
},
},
{
id: 'task-follow-up-notion',
cells: {
title: {
type: 'text',
value: 'Review consolidation timeline',
shortLabel: 'R',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Ivan Zhao',
tone: 'gray',
kind: 'person',
avatarUrl: avatars.ivanZhao,
},
dueDate: { type: 'text', value: 'Nov 4, 2023' },
relatedTo: {
type: 'entity',
name: 'Notion',
domain: 'notion.com',
},
status: { type: 'select', value: 'To Do' },
},
},
{
id: 'task-follow-up-github',
cells: {
title: {
type: 'text',
value: 'Check in on Copilot Rollout',
shortLabel: 'C',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Thomas Dohmke',
tone: 'gray',
kind: 'person',
avatarUrl: avatars.thomasDohmke,
},
dueDate: { type: 'text', value: 'Nov 5, 2023' },
relatedTo: {
type: 'entity',
name: 'Github',
domain: 'github.com',
},
status: { type: 'select', value: 'To Do' },
},
},
{
id: 'task-follow-up-airbnb',
cells: {
title: {
type: 'text',
value: 'Review Host Ops proposal',
shortLabel: 'R',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Brian Chesky',
tone: 'pink',
kind: 'person',
avatarUrl: avatars.brianChesky,
},
dueDate: { type: 'text', value: 'Nov 6, 2023' },
relatedTo: {
type: 'entity',
name: 'Airbnb',
domain: 'airbnb.com',
},
status: { type: 'select', value: 'To Do' },
},
},
{
id: 'task-follow-up-stripe',
cells: {
title: {
type: 'text',
value: 'Send billing expansion contract',
shortLabel: 'S',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Patrick Collison',
tone: 'blue',
kind: 'person',
avatarUrl: avatars.patrickCollison,
},
dueDate: { type: 'text', value: 'Nov 6, 2023' },
relatedTo: {
type: 'entity',
name: 'Stripe',
domain: 'stripe.com',
},
status: { type: 'select', value: 'To Do' },
},
},
{
id: 'task-follow-up-sequoia',
cells: {
title: {
type: 'text',
value: 'Schedule quarterly review',
shortLabel: 'S',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Roelof Botha',
tone: 'green',
kind: 'person',
avatarUrl: avatars.roelofBotha,
},
dueDate: { type: 'text', value: 'Nov 7, 2023' },
relatedTo: {
type: 'entity',
name: 'Sequoia',
domain: 'sequoia.com',
},
status: { type: 'select', value: 'To Do' },
},
},
{
id: 'task-follow-up-accel',
cells: {
title: {
type: 'text',
value: 'Follow up on Portfolio Sync',
shortLabel: 'F',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Ping Li',
tone: 'purple',
kind: 'person',
avatarUrl: avatars.pingLi,
},
dueDate: { type: 'text', value: 'Nov 7, 2023' },
relatedTo: {
type: 'entity',
name: 'Accel',
domain: 'accel.com',
},
status: { type: 'select', value: 'To Do' },
},
},
{
id: 'task-follow-up-google',
cells: {
title: {
type: 'text',
value: 'Prep AI Solutions deck',
shortLabel: 'P',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Sundar Pichai',
tone: 'teal',
kind: 'person',
avatarUrl: avatars.sundarPichai,
},
dueDate: { type: 'text', value: 'Nov 8, 2023' },
relatedTo: {
type: 'entity',
name: 'Google',
domain: 'google.com',
},
status: { type: 'select', value: 'To Do' },
},
},
];

View File

@@ -0,0 +1,10 @@
// Sidebar page items the AI scenes navigate to (ids from the shared
// APP_PREVIEW_CONFIG sidebar data).
export const PAGE_ITEM_IDS = {
companies: 'companies',
people: 'people',
opportunities: 'opportunities',
tasks: 'tasks',
workflowEmailSequence: 'workflow-send-email-sequence',
salesDashboard: 'sales-dashboard',
};

View File

@@ -0,0 +1,194 @@
import { sharedAssetUrls } from '../data/shared-asset-urls';
import { PAGE_ITEM_IDS } from './page-item-ids';
export type ResponseChip = {
logoUrl: string;
name: string;
};
type ProductVisualSceneKind =
| 'leadCreation'
| 'opportunityReview'
| 'taskCreation'
| 'dashboardCreation'
| 'workflowCreation';
export type AgentToolIcon =
| 'search'
| 'filter'
| 'notes'
| 'tasks'
| 'record'
| 'workflow'
| 'mail';
export type AgentStep =
| { kind: 'thinking'; durationMs: number }
| {
kind: 'tool';
icon: AgentToolIcon;
running: string;
done: string;
durationMs: number;
};
export type ProductVisualSceneDefinition = {
initialPageItemId: string;
kind: ProductVisualSceneKind;
label: string;
responseChips: ResponseChip[];
responseText: string[];
sidebarMode?: 'collapsed' | 'expanded';
steps?: AgentStep[];
};
function companyLogo(domain: string): string {
const url = sharedAssetUrls.companyLogoForDomain(domain);
if (url === undefined) {
throw new Error(`Missing shared company logo for "${domain}"`);
}
return url;
}
export const PRODUCT_VISUAL_SCENES: ProductVisualSceneDefinition[] = [
{
initialPageItemId: PAGE_ITEM_IDS.companies,
kind: 'leadCreation',
label: 'Add a new lead',
responseText: [],
responseChips: [],
sidebarMode: 'expanded',
},
{
initialPageItemId: PAGE_ITEM_IDS.opportunities,
kind: 'opportunityReview',
label: 'Build a pipeline board grouped by stage',
responseText: [
'Organized your open deals into a **pipeline board** grouped by stage — **New**, **Screening**, **Meeting**, **Proposal**, and **Customer**.',
'Drag a card to move a deal forward, or open one to see the full history.',
],
responseChips: [
{ name: 'Anthropic', logoUrl: companyLogo('anthropic.com') },
{ name: 'Notion', logoUrl: companyLogo('notion.com') },
{ name: 'Github', logoUrl: companyLogo('github.com') },
{ name: 'Airbnb', logoUrl: companyLogo('airbnb.com') },
{ name: 'Figma', logoUrl: companyLogo('figma.com') },
{ name: 'Stripe', logoUrl: companyLogo('stripe.com') },
{ name: 'Mailchimp', logoUrl: companyLogo('mailchimp.com') },
],
sidebarMode: 'collapsed',
steps: [
{ kind: 'thinking', durationMs: 1200 },
{
kind: 'tool',
icon: 'search',
running: 'Reading your open deals',
done: 'Read 24 deals',
durationMs: 1000,
},
{
kind: 'tool',
icon: 'record',
running: 'Building the pipeline board',
done: 'Built the board',
durationMs: 800,
},
],
},
{
initialPageItemId: PAGE_ITEM_IDS.tasks,
kind: 'taskCreation',
label:
'Generate follow-up tasks for my top 10 accounts using notes to gather context',
responseText: [
'Created **10 follow-up tasks** dated **Nov 1 through Nov 8**.',
'The first rows cover Anthropic, Slack, Figma, Notion, and Github, followed by Airbnb, Stripe, Sequoia, Accel, and Google.',
],
responseChips: [
{ name: 'Anthropic', logoUrl: companyLogo('anthropic.com') },
{ name: 'Slack', logoUrl: companyLogo('slack.com') },
{ name: 'Figma', logoUrl: companyLogo('figma.com') },
{ name: 'Notion', logoUrl: companyLogo('notion.com') },
{ name: 'Github', logoUrl: companyLogo('github.com') },
{ name: 'Airbnb', logoUrl: companyLogo('airbnb.com') },
{ name: 'Stripe', logoUrl: companyLogo('stripe.com') },
{ name: 'Sequoia', logoUrl: companyLogo('sequoia.com') },
{ name: 'Accel', logoUrl: companyLogo('accel.com') },
{ name: 'Google', logoUrl: companyLogo('google.com') },
],
sidebarMode: 'collapsed',
steps: [
{ kind: 'thinking', durationMs: 1200 },
{
kind: 'tool',
icon: 'notes',
running: 'Reading notes on your top 10 accounts',
done: 'Read 10 accounts',
durationMs: 1100,
},
{
kind: 'tool',
icon: 'tasks',
running: 'Creating 10 follow-up tasks',
done: 'Created 10 tasks',
durationMs: 900,
},
],
},
{
initialPageItemId: PAGE_ITEM_IDS.salesDashboard,
kind: 'dashboardCreation',
label: 'Build a dashboard of pipeline by stage and ARR',
responseText: [
'Built a **Sales dashboard** with live KPIs — **$12.9M pipeline**, **$2.4M won this quarter**, and a **38% win rate** — plus charts for deals by stage and ARR over time.',
'It refreshes automatically as your data changes.',
],
responseChips: [],
sidebarMode: 'collapsed',
steps: [
{ kind: 'thinking', durationMs: 1200 },
{
kind: 'tool',
icon: 'search',
running: 'Aggregating pipeline and revenue',
done: 'Aggregated 24 deals',
durationMs: 1000,
},
{
kind: 'tool',
icon: 'record',
running: 'Assembling the dashboard',
done: 'Built the dashboard',
durationMs: 900,
},
],
},
{
initialPageItemId: PAGE_ITEM_IDS.workflowEmailSequence,
kind: 'workflowCreation',
label: 'Draft a workflow that sends an email sequence',
responseText: [
'Created and activated a sequence with a **Manual trigger**, an **Iterator**, and a **Send Email** step.',
'It is ready to run now, and filters or email copy can be refined next.',
],
responseChips: [],
sidebarMode: 'collapsed',
steps: [
{ kind: 'thinking', durationMs: 1200 },
{
kind: 'tool',
icon: 'workflow',
running: 'Designing the workflow',
done: 'Designed 3 steps',
durationMs: 1000,
},
{
kind: 'tool',
icon: 'mail',
running: 'Activating the sequence',
done: 'Activated sequence',
durationMs: 800,
},
],
},
];

View File

@@ -0,0 +1,65 @@
import { streamedMarkdown } from './streamed-markdown';
const { getVisibleLength, sliceVisibleParagraphs } = streamedMarkdown;
describe('streamedMarkdown.getVisibleLength', () => {
it('should count characters without the bold markers', () => {
expect(getVisibleLength(['Built a **Sales dashboard** now.'])).toBe(
'Built a Sales dashboard now.'.length,
);
});
it('should sum across paragraphs', () => {
expect(getVisibleLength(['ab', '**cd**'])).toBe(4);
});
it('should return zero for no paragraphs', () => {
expect(getVisibleLength([])).toBe(0);
});
});
describe('streamedMarkdown.sliceVisibleParagraphs', () => {
it('should reveal a prefix across plain and bold runs', () => {
expect(sliceVisibleParagraphs(['ab **cd** ef'], 5)).toEqual([
[
{ bold: false, text: 'ab ' },
{ bold: true, text: 'cd' },
],
]);
});
it('should stop mid-bold and omit later paragraphs', () => {
expect(sliceVisibleParagraphs(['ab **cd**', 'ef'], 3)).toEqual([
[{ bold: false, text: 'ab ' }],
]);
});
it('should skip the empty bold run when the budget ends at a marker', () => {
const [paragraph] = sliceVisibleParagraphs(['ab **cd**'], 3);
expect(paragraph.some((segment) => segment.text === '')).toBe(false);
});
it('should span paragraphs once the first is fully revealed', () => {
expect(sliceVisibleParagraphs(['ab', 'cd'], 3)).toEqual([
[{ bold: false, text: 'ab' }],
[{ bold: false, text: 'c' }],
]);
});
it('should render every character at full length', () => {
const paragraphs = [
'Organized your open deals into a **pipeline board** grouped by stage.',
'Drag a card to move a deal forward.',
];
const revealed = sliceVisibleParagraphs(
paragraphs,
getVisibleLength(paragraphs),
);
const joined = revealed
.map((segments) => segments.map((segment) => segment.text).join(''))
.join('\n');
expect(joined).toBe(
'Organized your open deals into a pipeline board grouped by stage.\nDrag a card to move a deal forward.',
);
});
});

View File

@@ -0,0 +1,127 @@
// Incremental "**bold**" rendering for the streamed AI answer. The stream
// target (how many characters to reveal) and the render (which characters
// are shown) both depend on one definition of a "visible character" —
// anything except the `**` markers — so the two can never drift.
type MarkdownSegment = {
bold: boolean;
text: string;
};
function getParagraphVisibleLength(text: string): number {
let length = 0;
for (let index = 0; index < text.length; index += 1) {
if (text[index] === '*' && text[index + 1] === '*') {
index += 1;
continue;
}
length += 1;
}
return length;
}
function getVisibleLength(paragraphs: string[]): number {
return paragraphs.reduce(
(total, paragraph) => total + getParagraphVisibleLength(paragraph),
0,
);
}
function sliceParagraph(
text: string,
visibleLength: number,
): { consumed: number; segments: MarkdownSegment[] } {
const segments: MarkdownSegment[] = [];
let visibleRemaining = visibleLength;
let cursor = 0;
while (cursor < text.length && visibleRemaining > 0) {
const openIndex = text.indexOf('**', cursor);
if (openIndex === -1) {
const segment = text.slice(cursor, cursor + visibleRemaining);
segments.push({ bold: false, text: segment });
visibleRemaining -= segment.length;
break;
}
if (openIndex > cursor) {
const plainSegment = text.slice(cursor, openIndex);
const visiblePlainSegment = plainSegment.slice(0, visibleRemaining);
if (visiblePlainSegment.length > 0) {
segments.push({ bold: false, text: visiblePlainSegment });
visibleRemaining -= visiblePlainSegment.length;
}
if (visiblePlainSegment.length < plainSegment.length) {
break;
}
}
const closeIndex = text.indexOf('**', openIndex + 2);
if (closeIndex === -1) {
const trailingSegment = text.slice(
openIndex + 2,
openIndex + 2 + visibleRemaining,
);
if (trailingSegment.length > 0) {
segments.push({ bold: true, text: trailingSegment });
visibleRemaining -= trailingSegment.length;
}
break;
}
const boldSegment = text.slice(openIndex + 2, closeIndex);
const visibleBoldSegment = boldSegment.slice(0, visibleRemaining);
// Skip an empty run when the budget ends exactly at the marker.
if (visibleBoldSegment.length > 0) {
segments.push({ bold: true, text: visibleBoldSegment });
}
visibleRemaining -= visibleBoldSegment.length;
if (visibleBoldSegment.length < boldSegment.length) {
break;
}
cursor = closeIndex + 2;
}
return { consumed: visibleLength - visibleRemaining, segments };
}
// Reveal the first `visibleLength` visible characters across `paragraphs`,
// returning each non-empty paragraph as a run of bold/plain segments.
function sliceVisibleParagraphs(
paragraphs: string[],
visibleLength: number,
): MarkdownSegment[][] {
const result: MarkdownSegment[][] = [];
let visibleRemaining = visibleLength;
for (const paragraph of paragraphs) {
if (visibleRemaining <= 0) {
break;
}
const { consumed, segments } = sliceParagraph(paragraph, visibleRemaining);
if (segments.length > 0) {
result.push(segments);
}
visibleRemaining -= consumed;
}
return result;
}
export const streamedMarkdown = { getVisibleLength, sliceVisibleParagraphs };

View File

@@ -0,0 +1,59 @@
'use client';
import { useEffect, useState } from 'react';
import {
cursorTour,
type CursorTarget,
type TourPhase,
} from './cursor-tour-phases';
type ProductHeroTourState = {
activeCursor: number;
clicking: boolean;
glideMs?: number;
hidden: boolean;
pageItemId: string;
recordTab?: string;
showRecord: boolean;
target: CursorTarget;
};
export function useProductHeroCursorAutoplay(
enabled: boolean,
{ mobile = false }: { mobile?: boolean } = {},
): ProductHeroTourState {
const phases: TourPhase[] = mobile
? cursorTour.MOBILE_PHASES
: cursorTour.DESKTOP_PHASES;
const [phaseIndex, setPhaseIndex] = useState(0);
useEffect(() => {
if (!enabled) {
setPhaseIndex(0);
return undefined;
}
const timer = setTimeout(() => {
setPhaseIndex((current) =>
cursorTour.nextPhaseIndex(current, phases.length),
);
}, phases[phaseIndex].durationMs);
return () => clearTimeout(timer);
}, [enabled, phaseIndex, phases]);
const phase = phases[phaseIndex];
return {
activeCursor: phase.activeCursor,
clicking: phase.clicking,
glideMs: phase.glideMs,
hidden: phase.hidden,
pageItemId: phase.pageItemId,
recordTab: phase.recordTab,
showRecord: phase.showRecord,
target: phase.target,
};
}

View File

@@ -0,0 +1,125 @@
'use client';
import { useEffect, useState } from 'react';
import { displayPage } from './display-page';
import { PRODUCT_VISUAL_SCENES } from './product-visual-scenes';
import { streamedMarkdown } from './streamed-markdown';
import { useAppPreviewExperience } from '../shell/use-app-preview-experience';
import { useTimeoutRegistry } from '../stage/use-timeout-registry';
import { type AppPreviewConfig } from '../types';
const STREAM_TICK_MS = 20;
// Drives one AI scene end to end: the agent's steps run in sequence,
// then the answer streams at 50 chars/sec while the page beneath it
// "generates" — composing the same navigation experience the home
// mockup uses.
export function useProductVisualAutoplay(
visual: AppPreviewConfig,
{
externalScene,
playbackEnabled = true,
}: {
externalScene?: number;
playbackEnabled?: boolean;
} = {},
) {
const [streamedLength, setStreamedLength] = useState(0);
const [completedStepCount, setCompletedStepCount] = useState(0);
const timeouts = useTimeoutRegistry();
const selectedOption =
externalScene !== undefined
? Math.max(0, Math.min(externalScene, PRODUCT_VISUAL_SCENES.length - 1))
: 0;
const experience = useAppPreviewExperience(visual);
const { activeItemId, activePage, selectPageItem } = experience;
const selectedScene = PRODUCT_VISUAL_SCENES[selectedOption];
const fullTextVisibleLength = streamedMarkdown.getVisibleLength(
selectedScene.responseText,
);
const streamComplete = streamedLength >= fullTextVisibleLength;
const streamProgress = displayPage.getStreamProgress(
streamedLength,
fullTextVisibleLength,
);
const resolvedPage = displayPage.resolveDisplayPage(
activePage,
activeItemId,
selectedScene,
streamProgress,
);
useEffect(() => {
setStreamedLength(0);
setCompletedStepCount(0);
selectPageItem(selectedScene.initialPageItemId);
if (!playbackEnabled) {
return undefined;
}
const startStreaming = () => {
if (fullTextVisibleLength === 0) {
return;
}
let index = 0;
const tick = () => {
index = Math.min(index + 1, fullTextVisibleLength);
setStreamedLength(index);
if (index < fullTextVisibleLength) {
timeouts.schedule(tick, STREAM_TICK_MS);
}
};
timeouts.schedule(tick, STREAM_TICK_MS);
};
// Agentic preamble: play the steps in sequence, then stream the answer.
const steps = selectedScene.steps ?? [];
const playStep = (stepIndex: number) => {
if (stepIndex >= steps.length) {
startStreaming();
return;
}
timeouts.schedule(() => {
setCompletedStepCount(stepIndex + 1);
playStep(stepIndex + 1);
}, steps[stepIndex].durationMs);
};
playStep(0);
return () => {
timeouts.clearAll();
};
}, [
fullTextVisibleLength,
playbackEnabled,
selectPageItem,
selectedScene,
timeouts,
]);
const agentSteps = selectedScene.steps ?? [];
const preambleComplete = completedStepCount >= agentSteps.length;
const activeStepIndex = preambleComplete ? -1 : completedStepCount;
return {
...experience,
activeStepIndex,
agentSteps,
completedStepCount,
displayPage: resolvedPage,
selectedScene,
streamComplete,
streamedTextVisibleLength: streamedLength,
};
}

View File

@@ -0,0 +1,181 @@
'use client';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { mediaUp } from '@/tokens';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { renderPage } from '../pages/RenderPage';
import { PreviewNavbar } from './PreviewNavbar';
import { PreviewSidebar, type DesktopSidebarMode } from './PreviewSidebar';
import { PreviewViewbar } from './PreviewViewbar';
import {
type NavbarAction,
type PageDefinition,
type SidebarEntry,
type SidebarItemDef,
} from '../types';
const AppLayout = styled.div`
display: flex;
flex: 1 1 auto;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
position: relative;
width: 100%;
z-index: 1;
`;
const RightPane = styled.div`
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
min-width: 0;
padding-bottom: 12px;
padding-left: 0;
padding-right: 8px;
padding-top: 12px;
row-gap: 12px;
${mediaUp('md')} {
padding-right: 12px;
}
`;
const ContentRow = styled.div`
display: flex;
flex: 1 1 auto;
min-height: 0;
`;
// The morph squeezes the named workflow's canvas into the compact frame.
const IndexSurface = styled.div`
background: ${THEME_LIGHT.background.primary};
border: 1px solid ${THEME_LIGHT.border.color.medium};
border-radius: ${THEME_LIGHT.border.radius.md};
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
&[data-compact-workflow] [data-workflow-canvas] {
left: 0;
transform: scale(0.78) translateX(-6%);
transform-origin: top left;
}
`;
// The Ask-AI aside reveals through one CSS custom property the scroll
// choreography writes (no per-frame React work); static mounts inherit
// the fully-open default.
const AsideSlot = styled.div`
flex: 0 0 auto;
margin-left: calc(var(--ai-panel-progress, 1) * 8px);
overflow: hidden;
width: calc(var(--ai-panel-progress, 1) * 280px);
`;
// Phone (<600px) AI experience: drop the CRM sidebar/board and let the
// AI panel be the whole window.
const PanelOnlyRoot = styled.div`
display: flex;
flex: 1 1 auto;
height: 100%;
min-height: 0;
width: 100%;
`;
export function PreviewAppLayout({
activeItem,
activeItemId,
compactWorkflowPage = false,
desktopSidebarMode = 'expanded',
favorites,
highlightedItemId = null,
navbarActions,
navbarLabel,
onSelectPageItem,
onToggleFolder,
openFolderIds,
page,
panelOnly = false,
revealedObjectIds,
rightAside,
workspaceEntries,
}: {
activeItem?: SidebarItemDef;
activeItemId: string;
compactWorkflowPage?: boolean;
desktopSidebarMode?: DesktopSidebarMode;
favorites: SidebarItemDef[];
highlightedItemId?: string | null;
navbarActions?: NavbarAction[];
navbarLabel: string;
onSelectPageItem?: (itemId: string) => void;
onToggleFolder?: (folderId: string) => void;
openFolderIds?: string[];
page: PageDefinition;
panelOnly?: boolean;
revealedObjectIds: string[];
rightAside?: ReactNode;
workspaceEntries: SidebarEntry[];
}) {
if (panelOnly && rightAside !== undefined) {
return <PanelOnlyRoot>{rightAside}</PanelOnlyRoot>;
}
const showViewbar =
page.type !== 'dashboard' &&
page.type !== 'record' &&
page.type !== 'workflow';
const resolvedDesktopSidebarMode =
page.type === 'record' ? 'collapsed' : desktopSidebarMode;
return (
<AppLayout>
<PreviewSidebar
desktopMode={resolvedDesktopSidebarMode}
favorites={favorites}
highlightedItemId={highlightedItemId}
onSelectPageItem={onSelectPageItem}
onToggleFolder={onToggleFolder}
openFolderIds={openFolderIds}
selectedItemId={activeItemId}
workspace={workspaceEntries}
/>
<RightPane>
<PreviewNavbar
activeItem={activeItem}
activeItemLabel={navbarLabel}
navbarActions={navbarActions}
revealedObjectIds={revealedObjectIds}
/>
<ContentRow>
<IndexSurface
data-compact-workflow={compactWorkflowPage ? '' : undefined}
>
{showViewbar ? (
<PreviewViewbar
actions={page.header.actions ?? []}
count={page.header.count}
pageType={page.type}
showListIcon={page.header.showListIcon ?? false}
title={page.header.title}
/>
) : null}
{renderPage(page)}
</IndexSurface>
{rightAside !== undefined ? (
<AsideSlot>{rightAside}</AsideSlot>
) : null}
</ContentRow>
</RightPane>
</AppLayout>
);
}

View File

@@ -0,0 +1,339 @@
import { styled } from '@linaria/react';
import {
IconBox,
IconCalendarClock,
IconCalendarEvent,
IconCalendarPlus,
IconChevronDown,
IconChevronUp,
IconDotsVertical,
IconFlag,
IconHeart,
IconPlayerPause,
IconPlus,
IconRepeat,
IconRocket,
} from '@tabler/icons-react';
import { EASING, mediaUp } from '@/tokens';
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 { OBJECT_PINNED_ACTIONS } from '../data/object-pinned-actions';
import { renderPreviewIcon } from '../primitives/PreviewIcon';
import { type NavbarAction, type SidebarItemDef } from '../types';
const NAVBAR_ACTION_ICON_MAP: Record<string, typeof IconPlus> = {
box: IconBox,
calendarClock: IconCalendarClock,
calendarEvent: IconCalendarEvent,
calendarPlus: IconCalendarPlus,
chevronDown: IconChevronDown,
chevronUp: IconChevronUp,
dotsVertical: IconDotsVertical,
flag: IconFlag,
heart: IconHeart,
playerPause: IconPlayerPause,
plus: IconPlus,
repeat: IconRepeat,
rocket: IconRocket,
};
const NavbarBar = styled.div`
align-items: center;
background: transparent;
display: grid;
flex: 0 0 32px;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
height: 32px;
min-width: 0;
width: 100%;
`;
const Breadcrumb = styled.div`
align-items: center;
display: flex;
flex: 1 1 auto;
gap: 2px;
min-width: 0;
overflow: hidden;
`;
const BreadcrumbTag = styled.div`
align-items: center;
display: flex;
gap: 4px;
height: 20px;
min-width: 0;
padding: 0 2px;
`;
const BreadcrumbIconSlot = styled.span`
align-items: center;
display: flex;
flex: 0 0 16px;
height: 16px;
justify-content: center;
width: 16px;
`;
const CrumbLabel = styled.span`
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
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 NavbarActions = styled.div`
align-items: center;
display: flex;
flex: 0 1 auto;
gap: 8px;
justify-self: end;
max-width: 100%;
min-width: 0;
pointer-events: none;
`;
const DesktopOnlyAction = styled.div`
display: none;
flex: 0 1 auto;
min-width: 0;
${mediaUp('md')} {
display: block;
}
`;
const ActionButton = styled.div<{ $iconOnly?: boolean }>`
align-items: center;
background: transparent;
border: 1px solid ${THEME_LIGHT.background.transparent.medium};
border-radius: ${THEME_LIGHT.border.radius.sm};
display: inline-flex;
flex: 0 1 auto;
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.medium};
gap: ${APP_PREVIEW_CHROME.spacingBasePx}px;
height: 24px;
justify-content: center;
min-width: ${({ $iconOnly }) => ($iconOnly ? '24px' : '0')};
max-width: 100%;
padding: ${({ $iconOnly }) =>
$iconOnly ? '0' : `0 ${APP_PREVIEW_CHROME.spacingBasePx * 2}px`};
white-space: nowrap;
`;
const ActionIconWrap = styled.span`
align-items: center;
color: ${THEME_LIGHT.font.color.secondary};
display: flex;
flex: 0 0 auto;
justify-content: center;
`;
const ActionLabel = styled.span<{ $light?: boolean }>`
color: ${({ $light }) =>
$light ? THEME_LIGHT.font.color.light : THEME_LIGHT.font.color.secondary};
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1.4;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const DesktopOnlyTrailing = styled.div`
align-items: center;
display: none;
gap: ${APP_PREVIEW_CHROME.spacingBasePx}px;
height: 100%;
${mediaUp('md')} {
display: inline-flex;
}
`;
const ActionSeparator = styled.div`
background: ${THEME_LIGHT.background.transparent.medium};
border-radius: 56px;
height: 100%;
width: 1px;
`;
const PinnedActionButton = styled(ActionButton)<{
$pinnedActionIndex: number;
}>`
animation: pinnedActionIn 340ms ${EASING.standard} both;
animation-delay: calc(
${({ $pinnedActionIndex }) => $pinnedActionIndex} * 90ms
);
display: none;
gap: 4px;
padding: 0 6px;
${mediaUp('md')} {
display: inline-flex;
}
@keyframes pinnedActionIn {
from {
opacity: 0;
transform: translateY(-6px) scale(0.94);
}
60% {
opacity: 1;
transform: translateY(1px) scale(1.02);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
`;
function renderProvidedAction(action: NavbarAction, index: number) {
const Icon = NAVBAR_ACTION_ICON_MAP[action.icon];
return (
<ActionButton
$iconOnly={action.variant === 'icon' || !action.label}
key={`${action.icon}-${index}`}
>
{Icon ? (
<ActionIconWrap>
<Icon
aria-hidden
size={THEME_LIGHT.icon.size.sm}
stroke={THEME_LIGHT.icon.stroke.md}
/>
</ActionIconWrap>
) : null}
{action.label ? <ActionLabel>{action.label}</ActionLabel> : null}
{action.trailingLabel ? (
<DesktopOnlyTrailing>
<ActionSeparator />
<ActionLabel $light>{action.trailingLabel}</ActionLabel>
</DesktopOnlyTrailing>
) : null}
</ActionButton>
);
}
function renderPinnedAction(
action: NavbarAction,
index: number,
activeItemId: string | undefined,
) {
const Icon = NAVBAR_ACTION_ICON_MAP[action.icon];
return (
<PinnedActionButton
$pinnedActionIndex={index}
key={`pinned-${activeItemId}-${action.label}-${index}`}
>
{Icon ? (
<ActionIconWrap>
<Icon
aria-hidden
size={THEME_LIGHT.icon.size.sm}
stroke={THEME_LIGHT.icon.stroke.md}
/>
</ActionIconWrap>
) : null}
<ActionLabel>{action.label}</ActionLabel>
</PinnedActionButton>
);
}
export function PreviewNavbar({
activeItem,
activeItemLabel,
navbarActions,
revealedObjectIds = [],
}: {
activeItem?: SidebarItemDef;
activeItemLabel: string;
navbarActions?: NavbarAction[];
revealedObjectIds?: string[];
}) {
return (
<NavbarBar>
<Breadcrumb>
<BreadcrumbTag>
{activeItem ? (
<BreadcrumbIconSlot>
{renderPreviewIcon(activeItem.icon)}
</BreadcrumbIconSlot>
) : null}
<CrumbLabel>{activeItemLabel}</CrumbLabel>
</BreadcrumbTag>
</Breadcrumb>
<NavbarActions aria-hidden>
{navbarActions ? (
navbarActions.map(renderProvidedAction)
) : (
<DefaultActions
activeItem={activeItem}
revealedObjectIds={revealedObjectIds}
/>
)}
</NavbarActions>
</NavbarBar>
);
}
function DefaultActions({
activeItem,
revealedObjectIds,
}: {
activeItem?: SidebarItemDef;
revealedObjectIds: string[];
}) {
const pinnedActions =
activeItem && revealedObjectIds.includes(activeItem.id)
? OBJECT_PINNED_ACTIONS[activeItem.id]
: undefined;
return (
<>
{pinnedActions?.map((action, index) =>
renderPinnedAction(action, index, activeItem?.id),
)}
<DesktopOnlyAction>
<ActionButton>
<ActionIconWrap>
<IconPlus
aria-hidden
size={THEME_LIGHT.icon.size.sm}
stroke={THEME_LIGHT.icon.stroke.md}
/>
</ActionIconWrap>
<ActionLabel>New</ActionLabel>
</ActionButton>
</DesktopOnlyAction>
<ActionButton>
<ActionIconWrap>
<IconDotsVertical
aria-hidden
size={THEME_LIGHT.icon.size.sm}
stroke={THEME_LIGHT.icon.stroke.md}
/>
</ActionIconWrap>
<DesktopOnlyTrailing>
<ActionSeparator />
<ActionLabel $light>⌘K</ActionLabel>
</DesktopOnlyTrailing>
</ActionButton>
</>
);
}

View File

@@ -0,0 +1,280 @@
'use client';
import { styled } from '@linaria/react';
import {
IconChevronDown,
IconLayoutSidebarLeftCollapse,
IconSearch,
} from '@tabler/icons-react';
import { AppleRainbowMark } from '@/icons';
import { mediaUp } from '@/tokens';
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 { SidebarControls } from './SidebarControls';
import { SidebarFolder } from './SidebarFolder';
import { SidebarItem } from './SidebarItem';
import { renderPreviewIcon } from '../primitives/PreviewIcon';
import { isSidebarFolder } from './is-sidebar-folder';
import { type SidebarEntry, type SidebarItemDef } from '../types';
const SidebarPanel = styled.aside`
background: transparent;
display: flex;
flex-direction: column;
flex-shrink: 0;
min-height: 0;
padding: 8px 4px;
row-gap: 8px;
width: 48px;
${mediaUp('md')} {
&[data-desktop-mode='expanded'] {
padding: 12px 8px;
row-gap: 12px;
width: ${APP_PREVIEW_CHROME.navigationDrawerWidthPx}px;
}
}
`;
const Header = styled.div`
align-items: center;
display: flex;
flex-direction: column;
row-gap: 16px;
padding: 8px 4px;
${mediaUp('md')} {
[data-desktop-mode='expanded'] & {
flex-direction: row;
justify-content: space-between;
row-gap: 0;
}
}
`;
const HeaderLeft = styled.div`
align-items: center;
display: flex;
justify-content: center;
width: 100%;
${mediaUp('md')} {
[data-desktop-mode='expanded'] & {
column-gap: 8px;
justify-content: flex-start;
width: auto;
}
}
`;
const WorkspaceName = styled.span`
display: none;
${mediaUp('md')} {
[data-desktop-mode='expanded'] & {
display: block;
color: ${THEME_LIGHT.font.color.primary};
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.medium};
line-height: 1.4;
}
}
`;
const DesktopOnly = styled.span`
display: none;
${mediaUp('md')} {
[data-desktop-mode='expanded'] & {
display: flex;
}
}
`;
const HeaderRight = styled.div`
align-items: center;
display: flex;
flex-direction: column;
row-gap: 16px;
${mediaUp('md')} {
[data-desktop-mode='expanded'] & {
column-gap: 8px;
flex-direction: row;
}
}
`;
const SectionLabelRow = styled.div`
align-items: center;
display: flex;
height: 28px;
padding-left: 4px;
padding-right: 2px;
`;
const SectionLabel = styled.span`
color: ${THEME_LIGHT.font.color.light};
font-family: var(--font-product), sans-serif;
font-size: 11px;
font-weight: ${THEME_LIGHT.font.weight.semiBold};
line-height: 1;
`;
const SectionStack = styled.div`
display: flex;
flex-direction: column;
min-height: 0;
row-gap: 2px;
`;
// Mobile renders the icon rail; desktop the expanded panel.
const RailOnly = styled.div`
align-items: center;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
row-gap: 8px;
${mediaUp('md')} {
[data-desktop-mode='expanded'] & {
display: none;
}
}
`;
const DesktopContent = styled.div`
display: none;
${mediaUp('md')} {
[data-desktop-mode='expanded'] & {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
row-gap: 12px;
}
}
`;
const RailIconSlot = styled.div`
align-items: center;
display: flex;
height: 28px;
justify-content: center;
width: 100%;
`;
export type DesktopSidebarMode = 'expanded' | 'collapsed';
export function PreviewSidebar({
desktopMode = 'expanded',
favorites,
highlightedItemId = null,
onSelectPageItem,
onToggleFolder,
openFolderIds,
selectedItemId,
workspace,
}: {
desktopMode?: DesktopSidebarMode;
favorites: SidebarItemDef[];
highlightedItemId?: string | null;
onSelectPageItem?: (itemId: string) => void;
onToggleFolder?: (folderId: string) => void;
openFolderIds?: string[];
selectedItemId: string;
workspace: SidebarEntry[];
}) {
const visibleWorkspace = workspace.filter((entry) =>
isSidebarFolder(entry) ? true : !entry.hidden,
);
const railItems = visibleWorkspace.filter(
(entry): entry is SidebarItemDef => !isSidebarFolder(entry),
);
return (
<SidebarPanel aria-hidden data-desktop-mode={desktopMode}>
<Header>
<HeaderLeft>
<AppleRainbowMark sizePx={16} />
<WorkspaceName>Apple</WorkspaceName>
<DesktopOnly>
<IconChevronDown
color={THEME_LIGHT.font.color.extraLight}
size={16}
/>
</DesktopOnly>
</HeaderLeft>
<HeaderRight>
<IconSearch color={THEME_LIGHT.font.color.secondary} size={16} />
<DesktopOnly>
<IconLayoutSidebarLeftCollapse
color={THEME_LIGHT.font.color.secondary}
size={16}
/>
</DesktopOnly>
</HeaderRight>
</Header>
<SidebarControls />
<RailOnly>
{railItems.map((item) => (
<RailIconSlot data-rail-item-id={item.id} key={item.id}>
{renderPreviewIcon(item.icon)}
</RailIconSlot>
))}
</RailOnly>
<DesktopContent>
<div>
<SectionLabelRow>
<SectionLabel>Favorites</SectionLabel>
</SectionLabelRow>
<SectionStack>
{favorites.map((item) => (
<SidebarItem
active={item.id === selectedItemId}
item={item}
key={item.id}
onSelect={onSelectPageItem}
/>
))}
</SectionStack>
</div>
<div>
<SectionLabelRow>
<SectionLabel>Workspace</SectionLabel>
</SectionLabelRow>
<SectionStack>
{visibleWorkspace.map((entry) =>
isSidebarFolder(entry) ? (
<SidebarFolder
expanded={openFolderIds?.includes(entry.id) ?? false}
folder={entry}
key={entry.id}
onSelectItem={onSelectPageItem}
onToggleExpanded={
onToggleFolder ? () => onToggleFolder(entry.id) : undefined
}
selectedItemId={selectedItemId}
/>
) : (
<SidebarItem
active={entry.id === selectedItemId}
highlighted={entry.id === highlightedItemId}
item={entry}
key={entry.id}
onSelect={onSelectPageItem}
/>
),
)}
</SectionStack>
</div>
</DesktopContent>
</SidebarPanel>
);
}

View File

@@ -0,0 +1,147 @@
import { styled } from '@linaria/react';
import {
IconChevronDown,
IconLayoutKanban,
IconList,
} from '@tabler/icons-react';
import { mediaUp } from '@/tokens';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { previewFontSize } from '@/app-preview/preview-font-size';
import { MiniIcon } from '../primitives/MiniIcon';
import { type PageType } from '../types';
const ViewbarBar = styled.div`
align-items: center;
background: ${THEME_LIGHT.background.primary};
border-bottom: 1px solid ${THEME_LIGHT.border.color.light};
display: flex;
justify-content: space-between;
min-width: 0;
padding-bottom: 8px;
padding-left: 12px;
padding-right: 8px;
padding-top: 8px;
width: 100%;
`;
const ViewSwitcher = styled.div<{ $tableAligned?: boolean }>`
align-items: center;
display: flex;
flex: 1 1 auto;
gap: 4px;
height: 24px;
min-width: 0;
overflow: hidden;
padding-right: 4px;
padding-left: ${({ $tableAligned }) => ($tableAligned ? '0' : '4px')};
`;
const ViewName = styled.span`
color: ${THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.medium};
line-height: 1.4;
white-space: nowrap;
`;
const ViewCount = styled.span`
color: ${THEME_LIGHT.font.color.light};
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.medium};
line-height: 1.4;
white-space: nowrap;
`;
const TinyDot = styled.div`
background: ${THEME_LIGHT.border.color.strong};
border-radius: ${THEME_LIGHT.border.radius.pill};
height: 2px;
width: 2px;
`;
const ViewActions = styled.div`
align-items: center;
display: none;
flex: 0 0 auto;
gap: 2px;
margin-left: auto;
position: relative;
z-index: 1;
${mediaUp('md')} {
display: flex;
}
`;
const ViewAction = styled.span`
align-items: center;
border-radius: ${THEME_LIGHT.border.radius.sm};
color: ${THEME_LIGHT.font.color.secondary};
display: flex;
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.regular};
height: 24px;
line-height: 1.4;
padding: 4px 8px;
white-space: nowrap;
`;
type PreviewViewbarProps = {
actions: string[];
count?: number;
pageType: PageType;
showListIcon: boolean;
title: string;
};
export function PreviewViewbar({
actions,
count,
pageType,
showListIcon,
title,
}: PreviewViewbarProps) {
const showPageCount = count !== undefined;
const isTableAligned = pageType === 'table' && showListIcon;
return (
<ViewbarBar>
<ViewSwitcher $tableAligned={isTableAligned} aria-hidden>
{showListIcon ? (
<>
<MiniIcon
icon={pageType === 'kanban' ? IconLayoutKanban : IconList}
color={THEME_LIGHT.font.color.secondary}
size={16}
/>
<ViewName>{title}</ViewName>
{showPageCount ? (
<>
<TinyDot />
<ViewCount>{count}</ViewCount>
<MiniIcon
icon={IconChevronDown}
color={THEME_LIGHT.font.color.light}
/>
</>
) : null}
</>
) : (
<ViewName>{title}</ViewName>
)}
</ViewSwitcher>
{actions.length > 0 ? (
<ViewActions>
{actions.map((action) => (
<ViewAction key={action}>{action}</ViewAction>
))}
</ViewActions>
) : null}
</ViewbarBar>
);
}

View File

@@ -0,0 +1,118 @@
import { styled } from '@linaria/react';
import {
IconHome,
IconMessageCircle2,
IconMessageCirclePlus,
} from '@tabler/icons-react';
import { mediaUp } from '@/tokens';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { previewFontSize } from '@/app-preview/preview-font-size';
const Root = styled.div`
display: flex;
justify-content: center;
${mediaUp('md')} {
align-items: center;
justify-content: space-between;
}
`;
const SegmentedRail = styled.div`
display: none;
${mediaUp('md')} {
display: flex;
align-items: center;
/* The old hand-mixed #fcfcfccc: the secondary surface at 80%. */
background-color: color-mix(
in srgb,
${THEME_LIGHT.background.secondary} 80%,
transparent
);
border: 1px solid ${THEME_LIGHT.border.color.medium};
border-radius: ${THEME_LIGHT.border.radius.pill};
column-gap: 2px;
height: 26px;
padding: 3px;
}
`;
const Segment = styled.div<{ $selected?: boolean }>`
align-items: center;
background: ${({ $selected }) =>
$selected ? THEME_LIGHT.background.transparent.light : 'transparent'};
border-radius: ${THEME_LIGHT.border.radius.pill};
display: flex;
height: 20px;
justify-content: center;
width: 32px;
`;
const NewChat = styled.div`
align-items: center;
background-color: ${THEME_LIGHT.background.secondary};
border: 1px solid ${THEME_LIGHT.border.color.medium};
border-radius: ${THEME_LIGHT.border.radius.pill};
color: ${THEME_LIGHT.font.color.secondary};
display: flex;
height: 32px;
justify-content: center;
width: 32px;
${mediaUp('md')} {
column-gap: 4px;
height: 26px;
padding: 3px;
width: 103px;
}
`;
const NewChatLabel = styled.span`
display: none;
${mediaUp('md')} {
display: block;
font-family: var(--font-product), sans-serif;
font-size: ${previewFontSize(THEME_LIGHT.font.size.md)};
font-weight: ${THEME_LIGHT.font.weight.medium};
line-height: 1.4;
}
`;
// The home/chat rail + "New chat" pill — chrome only, as on the old
// site: the AI conversation lives in the floating Terminal.
export function SidebarControls() {
return (
<Root>
<SegmentedRail aria-hidden>
<Segment $selected>
<IconHome
aria-hidden
color={THEME_LIGHT.font.color.primary}
size={16}
strokeWidth={THEME_LIGHT.icon.stroke.md}
/>
</Segment>
<Segment>
<IconMessageCircle2
aria-hidden
color={THEME_LIGHT.font.color.tertiary}
size={16}
strokeWidth={THEME_LIGHT.icon.stroke.md}
/>
</Segment>
</SegmentedRail>
<NewChat aria-hidden>
<IconMessageCirclePlus
aria-hidden
color={THEME_LIGHT.font.color.secondary}
size={16}
strokeWidth={THEME_LIGHT.icon.stroke.md}
/>
<NewChatLabel>New chat</NewChatLabel>
</NewChat>
</Root>
);
}

View File

@@ -0,0 +1,136 @@
'use client';
import { styled } from '@linaria/react';
import { IconChevronDown } from '@tabler/icons-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 { SidebarItem } from './SidebarItem';
import { MiniIcon } from '../primitives/MiniIcon';
import { renderPreviewIcon } from '../primitives/PreviewIcon';
import { type SidebarFolderDef } from '../types';
const FolderButton = styled.button<{ $expanded?: boolean }>`
align-items: center;
appearance: none;
background: ${({ $expanded }) =>
$expanded ? THEME_LIGHT.background.transparent.light : 'transparent'};
border: 0;
border-radius: ${THEME_LIGHT.border.radius.sm};
cursor: pointer;
display: flex;
height: ${APP_PREVIEW_CHROME.navigationItemHeightPx}px;
justify-content: flex-start;
padding-left: 4px;
padding-right: 2px;
text-align: left;
transition: background-color 0.14s ease;
width: 100%;
&:hover {
background: ${THEME_LIGHT.background.transparent.light};
}
`;
const FolderRowMain = styled.div`
align-items: center;
column-gap: 8px;
display: flex;
flex: 1 1 auto;
min-width: 0;
`;
const FolderText = styled.span`
color: ${THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
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 FolderChevron = styled.div<{ $expanded?: boolean }>`
color: ${THEME_LIGHT.font.color.tertiary};
display: flex;
flex: 0 0 auto;
margin-left: auto;
transform: rotate(${({ $expanded }) => ($expanded ? '0deg' : '-90deg')});
transition: transform 0.16s ease;
`;
const ChildStack = styled.div`
display: flex;
flex-direction: column;
position: relative;
row-gap: 2px;
`;
const BranchLine = styled.div`
background: ${THEME_LIGHT.border.color.strong};
bottom: 14px;
left: 11px;
position: absolute;
top: 0;
width: 1px;
`;
export function SidebarFolder({
expanded,
folder,
onSelectItem,
onToggleExpanded,
selectedItemId,
}: {
expanded: boolean;
folder: SidebarFolderDef;
onSelectItem?: (itemId: string) => void;
onToggleExpanded?: () => void;
selectedItemId?: string;
}) {
const visibleItems = folder.items.filter((item) => !item.hidden);
const hasActiveChild = visibleItems.some(
(item) => item.id === selectedItemId,
);
return (
<>
<FolderButton
$expanded={expanded || hasActiveChild}
onClick={onToggleExpanded}
type="button"
>
<FolderRowMain>
{renderPreviewIcon(folder.icon)}
<FolderText>{folder.label}</FolderText>
</FolderRowMain>
<FolderChevron $expanded={expanded}>
<MiniIcon
color={THEME_LIGHT.font.color.tertiary}
icon={IconChevronDown}
size={12}
/>
</FolderChevron>
</FolderButton>
{expanded ? (
<ChildStack>
<BranchLine />
{visibleItems.map((item, index) => (
<SidebarItem
active={item.id === selectedItemId}
depth={1}
isLastChild={index === visibleItems.length - 1}
item={item}
key={item.id}
onSelect={onSelectItem}
/>
))}
</ChildStack>
) : null}
</>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import { styled } from '@linaria/react';
import { APP_PREVIEW_MOTION } from '@/tokens/app-preview/app-preview-motion';
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 { APP_PREVIEW_TONES } from '@/tokens/app-preview/app-preview-tones';
import { renderPreviewIcon } from '../primitives/PreviewIcon';
import { type SidebarItemDef } from '../types';
const ItemRow = styled.button<{
$active?: boolean;
$depth?: number;
$highlighted?: boolean;
$highlightRgb?: string;
}>`
--highlight-rgb: ${({ $highlightRgb }) => $highlightRgb ?? '237, 95, 0'};
--highlight-rest-background: ${THEME_LIGHT.background.transparent.light};
appearance: none;
border: 0;
text-align: left;
align-items: center;
animation: ${({ $highlighted }) =>
$highlighted
? `sidebarItemAppear 1800ms ${APP_PREVIEW_MOTION.revealPulseEase} both`
: 'none'};
background: ${({ $active }) =>
$active ? THEME_LIGHT.background.transparent.light : 'transparent'};
border-radius: ${THEME_LIGHT.border.radius.sm};
cursor: pointer;
display: flex;
height: ${APP_PREVIEW_CHROME.navigationItemHeightPx}px;
justify-content: flex-start;
padding-left: ${({ $depth = 0 }) => ($depth === 0 ? '4px' : '11px')};
padding-right: 2px;
position: relative;
transition: background-color 0.14s ease;
width: 100%;
&:hover {
background: ${THEME_LIGHT.background.transparent.light};
}
@keyframes sidebarItemAppear {
${APP_PREVIEW_MOTION.revealPulseFrames}
}
`;
// Folder children carry the connector guides.
const BranchCell = styled.div<{ $isLastChild?: boolean }>`
align-self: stretch;
flex: 0 0 9px;
position: relative;
&::before {
background: ${THEME_LIGHT.border.color.strong};
content: '';
inset: 0 8px 0 0;
opacity: ${({ $isLastChild }) => ($isLastChild ? 0 : 1)};
position: absolute;
}
&::after {
border-bottom: 1px solid ${THEME_LIGHT.border.color.strong};
border-left: 1px solid ${THEME_LIGHT.border.color.strong};
border-radius: 0 0 0 ${THEME_LIGHT.border.radius.sm};
content: '';
inset: 0 0 12px 0;
position: absolute;
}
`;
const RowMain = styled.div<{ $withBranch?: boolean }>`
align-items: center;
column-gap: 8px;
display: flex;
flex: 1 1 auto;
min-width: 0;
padding-left: ${({ $withBranch }) => ($withBranch ? '4px' : '0')};
`;
const ItemText = styled.div`
align-items: center;
column-gap: 2px;
display: flex;
min-width: 0;
`;
const ItemLabel = styled.span<{ $active?: boolean }>`
color: ${({ $active }) =>
$active
? THEME_LIGHT.font.color.primary
: THEME_LIGHT.font.color.secondary};
font-family: var(--font-product), sans-serif;
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 ItemMeta = styled.span`
color: ${THEME_LIGHT.font.color.light};
font-family: var(--font-product), sans-serif;
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;
`;
export function SidebarItem({
active = false,
depth = 0,
highlighted = false,
isLastChild = false,
item,
onSelect,
}: {
active?: boolean;
depth?: number;
highlighted?: boolean;
isLastChild?: boolean;
item: SidebarItemDef;
onSelect?: (itemId: string) => void;
}) {
const handleClick = () => {
if (item.href) {
window.open(item.href, '_blank', 'noreferrer');
return;
}
onSelect?.(item.id);
};
const highlightRgb =
item.icon.kind === 'tabler'
? APP_PREVIEW_TONES.sidebarToneRgb(item.icon.tone)
: undefined;
return (
<ItemRow
$active={active}
$depth={depth}
$highlighted={highlighted}
$highlightRgb={highlighted ? highlightRgb : undefined}
data-nav-item=""
onClick={handleClick}
type="button"
>
{depth > 0 ? <BranchCell $isLastChild={isLastChild} /> : null}
<RowMain $withBranch={depth > 0}>
{renderPreviewIcon(item.icon, highlighted)}
<ItemText>
<ItemLabel $active={active}>{item.label}</ItemLabel>
{item.meta ? <ItemMeta>· {item.meta}</ItemMeta> : null}
</ItemText>
</RowMain>
</ItemRow>
);
}

View File

@@ -0,0 +1,7 @@
import { type SidebarEntry, type SidebarFolderDef } from '../types';
export function isSidebarFolder(
entry: SidebarEntry,
): entry is SidebarFolderDef {
return Array.isArray((entry as SidebarFolderDef).items);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CRM_SCENARIO } from '../data/rocket-objects';
import { useTimeoutRegistry } from '../stage/use-timeout-registry';
import { type SidebarEntry } from '../types';
const COMPLETED_CREATED_OBJECT_IDS = CRM_SCENARIO.sequence.map(({ id }) => id);
const COMPLETED_REVEALED_OBJECT_IDS = [
...COMPLETED_CREATED_OBJECT_IDS,
CRM_SCENARIO.companiesItemId,
];
const HIGHLIGHT_RESET_DELAY_MS = 2000;
// The chat's object-creation beats land here: each created object prepends
// its sidebar item (newest first) and pulses its row for two seconds.
export function useAppPreviewCrmScenario(baseWorkspaceEntries: SidebarEntry[]) {
const timeoutRegistry = useTimeoutRegistry();
const [createdObjectIds, setCreatedObjectIds] = useState<string[]>([]);
const [revealedObjectIds, setRevealedObjectIds] = useState<string[]>([]);
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(
null,
);
const workspaceEntries = useMemo<SidebarEntry[]>(() => {
if (createdObjectIds.length === 0) {
return baseWorkspaceEntries;
}
const prependedEntries = createdObjectIds
.toReversed()
.map(
(id) =>
CRM_SCENARIO.sequence.find((candidate) => candidate.id === id)
?.sidebarItem,
)
.filter(
(entry): entry is NonNullable<typeof entry> => entry !== undefined,
);
return [...prependedEntries, ...baseWorkspaceEntries];
}, [baseWorkspaceEntries, createdObjectIds]);
const handleObjectCreated = useCallback((id: string) => {
setRevealedObjectIds((current) =>
current.includes(id) ? current : [...current, id],
);
setHighlightedItemId(id);
if (id === CRM_SCENARIO.companiesItemId) {
return;
}
const sequenceEntry = CRM_SCENARIO.sequence.find(
(candidate) => candidate.id === id,
);
if (!sequenceEntry) {
throw new Error(
`AppPreview CRM scenario does not support object id "${id}".`,
);
}
setCreatedObjectIds((current) =>
current.includes(id) ? current : [...current, id],
);
}, []);
const handleScenarioReset = useCallback(() => {
setCreatedObjectIds([]);
setRevealedObjectIds([]);
setHighlightedItemId(null);
}, []);
const handleJumpToConversationEnd = useCallback(() => {
setCreatedObjectIds(COMPLETED_CREATED_OBJECT_IDS);
setRevealedObjectIds(COMPLETED_REVEALED_OBJECT_IDS);
setHighlightedItemId(null);
}, []);
useEffect(() => {
if (highlightedItemId === null) {
return undefined;
}
return timeoutRegistry.schedule(
() => setHighlightedItemId(null),
HIGHLIGHT_RESET_DELAY_MS,
);
}, [highlightedItemId, timeoutRegistry]);
return {
handleJumpToConversationEnd,
handleObjectCreated,
handleScenarioReset,
highlightedItemId,
revealedObjectIds,
workspaceEntries,
};
}

View File

@@ -0,0 +1,86 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CRM_SCENARIO } from '../data/rocket-objects';
import { type AppPreviewConfig } from '../types';
import { useAppPreviewCrmScenario } from './use-app-preview-crm-scenario';
import { useAppPreviewNavigation } from './use-app-preview-navigation';
const COMPLETED_ACTIVE_OBJECT_ID = CRM_SCENARIO.sequence.at(-1)!.id;
// Navigation composed with the AI scenario: the sidebar the user navigates
// is the scenario-extended one, and a created object's page jump waits
// until its sidebar item exists (the pending selection).
export function useAppPreviewExperience(visual: AppPreviewConfig) {
const scenario = useAppPreviewCrmScenario(visual.sidebar.workspace);
const scenarioConfig = useMemo<AppPreviewConfig>(
() => ({
...visual,
sidebar: {
...visual.sidebar,
workspace: scenario.workspaceEntries,
},
}),
[scenario.workspaceEntries, visual],
);
const navigation = useAppPreviewNavigation(scenarioConfig);
const {
handleJumpToConversationEnd: completeScenario,
handleObjectCreated: revealObject,
handleScenarioReset,
highlightedItemId,
revealedObjectIds,
} = scenario;
const { canSelectPageItem, resetNavigation, selectPageItem } = navigation;
const [pendingPageItemSelectionId, setPendingPageItemSelectionId] = useState<
string | null
>(null);
useEffect(() => {
if (
pendingPageItemSelectionId === null ||
!canSelectPageItem(pendingPageItemSelectionId)
) {
return;
}
selectPageItem(pendingPageItemSelectionId);
setPendingPageItemSelectionId(null);
}, [canSelectPageItem, pendingPageItemSelectionId, selectPageItem]);
const handleObjectCreated = useCallback(
(id: string) => {
revealObject(id);
if (id === CRM_SCENARIO.companiesItemId) {
selectPageItem(CRM_SCENARIO.companiesItemId);
return;
}
setPendingPageItemSelectionId(id);
},
[revealObject, selectPageItem],
);
const handleChatReset = useCallback(() => {
setPendingPageItemSelectionId(null);
handleScenarioReset();
resetNavigation();
}, [handleScenarioReset, resetNavigation]);
const handleJumpToConversationEnd = useCallback(() => {
completeScenario();
setPendingPageItemSelectionId(COMPLETED_ACTIVE_OBJECT_ID);
}, [completeScenario]);
return {
...navigation,
handleChatReset,
handleJumpToConversationEnd,
handleObjectCreated,
highlightedItemId,
revealedObjectIds,
sidebarEntries: scenario.workspaceEntries,
};
}

View File

@@ -0,0 +1,169 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { isSidebarFolder } from './is-sidebar-folder';
import { normalizePage } from '../data/normalize-page';
import {
type AppPreviewConfig,
type PageDefinition,
type SidebarEntry,
type SidebarFolderDef,
type SidebarItemDef,
type SidebarPageItemDef,
} from '../types';
// The product table canvas the mockup was authored against: wider than
// the columns so the filler "+" column renders.
const DEFAULT_TABLE_WIDTH = 1700;
type SidebarIndex = {
foldersById: Map<string, SidebarFolderDef>;
pageItemsById: Map<string, SidebarPageItemDef>;
parentFolderIdsByItemId: Map<string, string>;
};
function isPageItem(item: SidebarItemDef): item is SidebarPageItemDef {
return item.page !== undefined;
}
function buildSidebarIndex(config: {
favorites: SidebarItemDef[];
workspace: SidebarEntry[];
}): SidebarIndex {
const foldersById = new Map<string, SidebarFolderDef>();
const pageItemsById = new Map<string, SidebarPageItemDef>();
const parentFolderIdsByItemId = new Map<string, string>();
const register = (item: SidebarItemDef, parentFolderId?: string) => {
if (isPageItem(item)) {
pageItemsById.set(item.id, item);
}
if (parentFolderId) {
parentFolderIdsByItemId.set(item.id, parentFolderId);
}
};
for (const item of config.favorites) {
register(item);
}
for (const entry of config.workspace) {
if (isSidebarFolder(entry)) {
foldersById.set(entry.id, entry);
for (const item of entry.items) {
register(item, entry.id);
}
continue;
}
register(entry);
}
return { foldersById, pageItemsById, parentFolderIdsByItemId };
}
export type AppPreviewNavigation = {
activeItem: SidebarPageItemDef;
activeItemId: string;
activePage: PageDefinition;
canSelectPageItem: (itemId: string) => boolean;
openFolderIds: string[];
resetNavigation: () => void;
selectPageItem: (itemId: string) => void;
toggleFolder: (folderId: string) => void;
};
function buildInitialOpenFolderIds(
visual: AppPreviewConfig,
navigationIndex: SidebarIndex,
): string[] {
const open = new Set(visual.sidebar.initialOpenFolderIds);
const parent = navigationIndex.parentFolderIdsByItemId.get(
visual.sidebar.initialActiveItemId,
);
if (parent) {
open.add(parent);
}
return [...open];
}
export function useAppPreviewNavigation(
visual: AppPreviewConfig,
): AppPreviewNavigation {
const navigationIndex = useMemo(
() =>
buildSidebarIndex({
favorites: visual.sidebar.favorites,
workspace: visual.sidebar.workspace,
}),
[visual.sidebar.favorites, visual.sidebar.workspace],
);
const [activeItemId, setActiveItemId] = useState(
visual.sidebar.initialActiveItemId,
);
const [openFolderIds, setOpenFolderIds] = useState(() =>
buildInitialOpenFolderIds(visual, navigationIndex),
);
const activeItem = navigationIndex.pageItemsById.get(activeItemId);
if (!activeItem) {
throw new Error(
`AppPreview attempted to select unknown page item id "${activeItemId}".`,
);
}
const activePage = useMemo(
() =>
normalizePage(activeItem, {
defaultActions: visual.defaultViewbarActions,
defaultTableWidth: DEFAULT_TABLE_WIDTH,
}),
[activeItem, visual.defaultViewbarActions],
);
const selectPageItem = useCallback(
(itemId: string) => {
if (!navigationIndex.pageItemsById.has(itemId)) {
return;
}
setActiveItemId(itemId);
const containingFolderId =
navigationIndex.parentFolderIdsByItemId.get(itemId);
if (!containingFolderId) {
return;
}
setOpenFolderIds((current) =>
current.includes(containingFolderId)
? current
: [...current, containingFolderId],
);
},
[navigationIndex],
);
const toggleFolder = useCallback((folderId: string) => {
setOpenFolderIds((current) =>
current.includes(folderId)
? current.filter((id) => id !== folderId)
: [...current, folderId],
);
}, []);
const canSelectPageItem = useCallback(
(itemId: string) => navigationIndex.pageItemsById.has(itemId),
[navigationIndex],
);
const resetNavigation = useCallback(() => {
setActiveItemId(visual.sidebar.initialActiveItemId);
setOpenFolderIds(buildInitialOpenFolderIds(visual, navigationIndex));
}, [navigationIndex, visual]);
return {
activeItem,
activeItemId,
activePage,
canSelectPageItem,
openFolderIds,
resetNavigation,
selectPageItem,
toggleFolder,
};
}

View File

@@ -0,0 +1,240 @@
'use client';
import { styled } from '@linaria/react';
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { mediaUp } from '@/tokens';
import { APP_PREVIEW_STAGE } from '@/tokens/app-preview/app-preview-stage';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { useWindowOrder } from './use-window-order';
import { useWindowPointerInteractions } from './use-window-pointer-interactions';
import { WindowBar } from './WindowBar';
import { type WindowPosition, type WindowSize } from './window-geometry';
const WINDOW_ID = 'twenty-app-window';
const MIN_WIDTH = 640;
const MIN_HEIGHT = 420;
const MIN_EDGE_GAP = 0;
const INITIAL_MAX_WIDTH = APP_PREVIEW_STAGE.windowScene.widthPx;
const Shell = styled.div<{ $isActive: boolean; $isReady: boolean }>`
background-color: ${THEME_LIGHT.background.primary};
background-image: url('${APP_PREVIEW_STAGE.frame.noiseImageUrl}');
border: 1px solid ${THEME_LIGHT.border.color.medium};
border-radius: ${APP_PREVIEW_STAGE.frame.borderRadiusPx}px;
box-shadow: ${({ $isActive }) =>
$isActive
? APP_PREVIEW_STAGE.shadow.mobileElevated
: APP_PREVIEW_STAGE.shadow.mobileResting};
display: flex;
flex-direction: column;
left: 0;
opacity: ${({ $isReady }) => ($isReady ? 1 : 0)};
overflow: hidden;
position: absolute;
top: 0;
touch-action: none;
transition:
box-shadow 0.22s ease,
opacity 0.1s ease;
will-change: transform, width, height;
${mediaUp('md')} {
box-shadow: ${({ $isActive }) =>
$isActive
? APP_PREVIEW_STAGE.shadow.elevated
: APP_PREVIEW_STAGE.shadow.resting};
}
`;
const Content = styled.div`
display: flex;
flex: 1 1 auto;
min-height: 0;
width: 100%;
`;
const ResizeEdgeBase = styled.div`
position: absolute;
z-index: 4;
`;
const ResizeEdgeTop = styled(ResizeEdgeBase)`
cursor: ns-resize;
height: 6px;
left: 40px;
right: 40px;
top: -3px;
`;
const ResizeEdgeBottom = styled(ResizeEdgeBase)`
bottom: -3px;
cursor: ns-resize;
height: 6px;
left: 40px;
right: 40px;
`;
const ResizeEdgeLeft = styled(ResizeEdgeBase)`
bottom: 16px;
cursor: ew-resize;
left: -3px;
top: 16px;
width: 6px;
`;
const ResizeEdgeRight = styled(ResizeEdgeBase)`
bottom: 16px;
cursor: ew-resize;
right: -3px;
top: 16px;
width: 6px;
`;
const ResizeCornerBase = styled.div`
height: 16px;
position: absolute;
width: 16px;
z-index: 5;
`;
const ResizeCornerTopLeft = styled(ResizeCornerBase)`
cursor: nwse-resize;
left: -4px;
top: -4px;
`;
const ResizeCornerTopRight = styled(ResizeCornerBase)`
cursor: nesw-resize;
right: -4px;
top: -4px;
`;
const ResizeCornerBottomLeft = styled(ResizeCornerBase)`
bottom: -4px;
cursor: nesw-resize;
left: -4px;
`;
const ResizeCornerBottomRight = styled(ResizeCornerBase)`
bottom: -4px;
cursor: nwse-resize;
right: -4px;
`;
export function AppWindow({ children }: { children: ReactNode }) {
const shellRef = useRef<HTMLDivElement>(null);
const interactingRef = useRef(false);
const [position, setPosition] = useState<WindowPosition | null>(null);
const [size, setSize] = useState<WindowSize | null>(null);
const { activate, zIndex } = useWindowOrder(WINDOW_ID);
const recalcLayout = () => {
const shell = shellRef.current;
const parent = shell?.parentElement;
if (!parent) {
return;
}
const parentRect = parent.getBoundingClientRect();
const newWidth = INITIAL_MAX_WIDTH;
const newHeight = parentRect.height;
setSize({ width: newWidth, height: newHeight });
setPosition({
left: Math.max(0, (parentRect.width - newWidth) / 2),
top: MIN_EDGE_GAP,
});
};
useLayoutEffect(() => {
recalcLayout();
const parent = shellRef.current?.parentElement;
if (!parent) {
return;
}
const observer = new ResizeObserver(() => {
if (!interactingRef.current) {
recalcLayout();
}
});
observer.observe(parent);
return () => observer.disconnect();
// recalcLayout reads only refs; mount-time wiring is intentional.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getParentRect = () => {
const parent = shellRef.current?.parentElement;
return parent?.getBoundingClientRect() ?? null;
};
const {
handleDragStart,
isDragging,
isResizing,
latestPositionRef,
latestSizeRef,
startResize,
} = useWindowPointerInteractions({
activate,
blockedDragTargetSelector:
'button, a, input, textarea, select, [role="button"]',
edgeGap: MIN_EDGE_GAP,
getBounds: getParentRect,
minSize: { width: MIN_WIDTH, height: MIN_HEIGHT },
position,
setPosition,
setSize,
shellRef,
size,
});
const isReady = position !== null && size !== null;
const isInteracting = isDragging || isResizing;
interactingRef.current = isInteracting;
const renderPosition = isInteracting
? (latestPositionRef.current ?? position)
: position;
const renderSize = isInteracting ? (latestSizeRef.current ?? size) : size;
return (
<Shell
$isActive={isReady && zIndex > 2}
$isReady={isReady}
data-app-window=""
onPointerDown={activate}
ref={shellRef}
style={{
height: renderSize ? `${renderSize.height}px` : undefined,
transform: renderPosition
? `translate3d(${renderPosition.left}px, ${renderPosition.top}px, 0)`
: 'translate3d(0, 0, 0)',
width: renderSize ? `${renderSize.width}px` : '100%',
zIndex,
}}
>
<ResizeEdgeTop onPointerDown={startResize('top')} />
<ResizeEdgeRight onPointerDown={startResize('right')} />
<ResizeEdgeBottom onPointerDown={startResize('bottom')} />
<ResizeEdgeLeft onPointerDown={startResize('left')} />
<ResizeCornerTopLeft
aria-hidden
onPointerDown={startResize('top-left')}
/>
<ResizeCornerTopRight
aria-hidden
onPointerDown={startResize('top-right')}
/>
<ResizeCornerBottomLeft
aria-hidden
onPointerDown={startResize('bottom-left')}
/>
<ResizeCornerBottomRight
aria-hidden
onPointerDown={startResize('bottom-right')}
/>
<WindowBar isDragging={isDragging} onDragStart={handleDragStart} />
<Content>{children}</Content>
</Shell>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { type ComponentType, type RefCallback } from 'react';
import { createPortal } from 'react-dom';
import { Z_INDEX } from '@/tokens';
import { APP_PREVIEW_MOTION } from '@/tokens/app-preview/app-preview-motion';
import { APP_PREVIEW_STAGE } from '@/tokens/app-preview/app-preview-stage';
import { TRAFFIC_LIGHT_DOT_DEFINITIONS } from './TrafficLightDefinitions';
const DOT_SIZE = 12;
const FlyingDotContainer = styled.button`
background: transparent;
border: none;
cursor: default;
display: block;
height: ${DOT_SIZE}px;
left: 0;
padding: 0;
pointer-events: none;
position: fixed;
top: 0;
width: ${DOT_SIZE}px;
z-index: ${Z_INDEX.portal};
&[data-resting='true'] {
cursor: pointer;
pointer-events: auto;
}
`;
const FlyingDotBall = styled.span<{
$background: string;
$backgroundActive: string;
}>`
align-items: center;
background: ${({ $background }) => $background};
border-radius: 999px;
box-shadow: inset 0 0 0 0.5px ${APP_PREVIEW_STAGE.trafficLight.ring};
display: flex;
height: 100%;
justify-content: center;
transform-origin: center;
transition: background-color 0.12s ease;
width: 100%;
svg {
opacity: 0;
transition: opacity 0.12s ease;
}
[data-resting='true']:hover > & {
background: ${({ $backgroundActive }) => $backgroundActive};
}
[data-resting='true']:hover > & svg {
opacity: 1;
}
&[data-returning='true'] {
animation: dotPop 0.38s ${APP_PREVIEW_MOTION.revealPulseEase} forwards;
}
@keyframes dotPop {
0% {
opacity: 1;
transform: scale(1);
}
40% {
opacity: 1;
transform: scale(1.55);
}
100% {
opacity: 0;
transform: scale(0);
}
}
`;
function FlyingDot({
background,
backgroundActive,
Glyph,
onAnimationEnd,
returning,
}: {
background: string;
backgroundActive: string;
Glyph: ComponentType;
onAnimationEnd: () => void;
returning: boolean;
}) {
return (
<FlyingDotBall
$background={background}
$backgroundActive={backgroundActive}
data-returning={returning ? 'true' : 'false'}
onAnimationEnd={onAnimationEnd}
>
<Glyph />
</FlyingDotBall>
);
}
export function FlyingTrafficLights({
onCatchDot,
onPopAnimationEnd,
portalReady,
returningDots,
returnedDots,
setFlyingRef,
visible,
}: {
onCatchDot: (index: number) => void;
onPopAnimationEnd: (index: number) => void;
portalReady: boolean;
returningDots: boolean[];
returnedDots: boolean[];
setFlyingRef: (index: number) => RefCallback<HTMLButtonElement>;
visible: boolean;
}) {
const { i18n } = useLingui();
if (!visible || !portalReady || typeof document === 'undefined') {
return null;
}
return createPortal(
<>
{TRAFFIC_LIGHT_DOT_DEFINITIONS.map(
({ background, backgroundActive, Glyph, label }, index) =>
returnedDots[index] ? null : (
<FlyingDotContainer
key={label}
aria-label={i18n._(msg`Return traffic light`)}
onClick={() => onCatchDot(index)}
ref={setFlyingRef(index)}
type="button"
>
<FlyingDot
background={background}
backgroundActive={backgroundActive}
Glyph={Glyph}
returning={returningDots[index] === true}
onAnimationEnd={() => onPopAnimationEnd(index)}
/>
</FlyingDotContainer>
),
)}
</>,
document.body,
);
}

View File

@@ -0,0 +1,75 @@
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { mediaUp } from '@/tokens';
import { APP_PREVIEW_STAGE } from '@/tokens/app-preview/app-preview-stage';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { WindowBar } from './WindowBar';
const FrameRoot = styled.div`
align-items: stretch;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
`;
// The frame fills its host — the host's scene box owns the window
// geometry (single source: APP_PREVIEW_STAGE.windowScene; the old site's
// redundant aspect-ratio was ratified out). compact lets a fluid scene
// reflow the board full-bleed; the default centers under the scene cap.
// floatingShadow is the product hero's downward-biased presentation.
const Shell = styled.div<{ $compact: boolean; $floatingShadow: boolean }>`
background-color: ${THEME_LIGHT.background.primary};
background-image: url('${APP_PREVIEW_STAGE.frame.noiseImageUrl}');
border: 1px solid ${THEME_LIGHT.border.color.medium};
border-radius: ${APP_PREVIEW_STAGE.frame.borderRadiusPx}px;
box-shadow: ${({ $floatingShadow }) =>
$floatingShadow
? APP_PREVIEW_STAGE.shadow.mobileFloating
: APP_PREVIEW_STAGE.shadow.mobileResting};
display: flex;
flex-direction: column;
height: 100%;
max-width: ${({ $compact }) =>
$compact ? 'none' : `${APP_PREVIEW_STAGE.frame.maxWidthPx}px`};
overflow: hidden;
position: relative;
width: 100%;
${mediaUp('md')} {
box-shadow: ${({ $floatingShadow }) =>
$floatingShadow
? APP_PREVIEW_STAGE.shadow.floating
: APP_PREVIEW_STAGE.shadow.resting};
}
`;
const Content = styled.div`
display: flex;
flex: 1 1 auto;
min-height: 0;
width: 100%;
`;
// The static product frame: the non-interactive presentation the
// product pages mount (the home hero uses AppWindow).
export function ProductFrame({
children,
compact = false,
floatingShadow = false,
}: {
children: ReactNode;
compact?: boolean;
floatingShadow?: boolean;
}) {
return (
<FrameRoot>
<Shell $compact={compact} $floatingShadow={floatingShadow}>
<WindowBar />
<Content>{children}</Content>
</Shell>
</FrameRoot>
);
}

View File

@@ -0,0 +1,71 @@
import { type ComponentType } from 'react';
import { APP_PREVIEW_STAGE } from '@/tokens/app-preview/app-preview-stage';
// The three macOS dots: colors from the stage tokens, 6x6 svg glyphs that
// reveal on hover. Shared by the in-bar dots and the escaped flying ones.
function CloseGlyph() {
return (
<svg aria-hidden width="6" height="6" viewBox="0 0 6 6">
<path
d="M1 1 L5 5 M5 1 L1 5"
stroke={APP_PREVIEW_STAGE.trafficLight.glyph}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
function MinimizeGlyph() {
return (
<svg aria-hidden width="6" height="6" viewBox="0 0 6 6">
<path
d="M1 3 L5 3"
stroke={APP_PREVIEW_STAGE.trafficLight.glyph}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
function ZoomGlyph() {
return (
<svg aria-hidden width="6" height="6" viewBox="0 0 6 6">
<path
d="M1.5 1.5 L1.5 4.5 L4.5 4.5 Z M4.5 1.5 L1.5 4.5"
fill={APP_PREVIEW_STAGE.trafficLight.glyph}
/>
</svg>
);
}
type TrafficLightDotDefinition = {
background: string;
backgroundActive: string;
Glyph: ComponentType;
label: 'Close' | 'Minimize' | 'Zoom';
};
export const TRAFFIC_LIGHT_DOT_DEFINITIONS: ReadonlyArray<TrafficLightDotDefinition> =
[
{
background: APP_PREVIEW_STAGE.trafficLight.close,
backgroundActive: APP_PREVIEW_STAGE.trafficLight.closeActive,
Glyph: CloseGlyph,
label: 'Close',
},
{
background: APP_PREVIEW_STAGE.trafficLight.minimize,
backgroundActive: APP_PREVIEW_STAGE.trafficLight.minimizeActive,
Glyph: MinimizeGlyph,
label: 'Minimize',
},
{
background: APP_PREVIEW_STAGE.trafficLight.zoom,
backgroundActive: APP_PREVIEW_STAGE.trafficLight.zoomActive,
Glyph: ZoomGlyph,
label: 'Zoom',
},
];

View File

@@ -0,0 +1,138 @@
'use client';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { type MessageDescriptor } from '@lingui/core';
import { type MouseEvent as ReactMouseEvent } from 'react';
import { APP_PREVIEW_STAGE } from '@/tokens/app-preview/app-preview-stage';
import { FlyingTrafficLights } from './FlyingTrafficLights';
import { TRAFFIC_LIGHT_DOT_DEFINITIONS } from './TrafficLightDefinitions';
import { useTrafficLightsEscape } from './use-traffic-lights-escape';
// The macOS window controls, ported as-is: every instance (app window AND
// terminal) is escape-capable — the prompt egg's window event sends all
// six dots flying. Zoom triple-click is the terminal's functional
// jump-to-end; close and minimize press but do nothing.
const DOT_ARIA_LABELS: Record<
(typeof TRAFFIC_LIGHT_DOT_DEFINITIONS)[number]['label'],
MessageDescriptor
> = {
Close: msg`Close`,
Minimize: msg`Minimize`,
Zoom: msg`Zoom`,
};
const Container = styled.div`
align-items: center;
display: flex;
gap: 8px;
padding: 0 6px;
`;
const Dot = styled.button<{ $background: string; $backgroundActive: string }>`
align-items: center;
background: ${({ $background }) => $background};
border: none;
border-radius: 999px;
cursor: pointer;
display: flex;
flex: 0 0 auto;
height: 12px;
justify-content: center;
padding: 0;
position: relative;
transition:
background-color 0.12s ease,
transform 0.12s ease;
width: 12px;
&::after {
border-radius: 999px;
box-shadow: inset 0 0 0 0.5px ${APP_PREVIEW_STAGE.trafficLight.ring};
content: '';
inset: 0;
pointer-events: none;
position: absolute;
}
svg {
opacity: 0;
transition: opacity 0.12s ease;
}
&:hover {
background: ${({ $backgroundActive }) => $backgroundActive};
svg {
opacity: 1;
}
}
&:active {
transform: scale(0.92);
}
&[data-escaping='true'] {
pointer-events: none;
visibility: hidden;
}
`;
export function TrafficLights({
onZoomTripleClick,
}: {
onZoomTripleClick?: () => void;
} = {}) {
const { i18n } = useLingui();
const {
handleCatchDot,
handlePopAnimationEnd,
isEscaping,
portalReady,
returnedDots,
returningDots,
setFlyingRef,
setOriginalRef,
} = useTrafficLightsEscape();
const handleZoomClick = (event: ReactMouseEvent<HTMLElement>) => {
if (event.detail === 3) {
onZoomTripleClick?.();
}
};
return (
<Container aria-label={i18n._(msg`Window controls`)}>
{TRAFFIC_LIGHT_DOT_DEFINITIONS.map(
({ background, backgroundActive, Glyph, label }, index) => (
<Dot
$background={background}
$backgroundActive={backgroundActive}
aria-label={i18n._(DOT_ARIA_LABELS[label])}
data-escaping={
isEscaping && !returnedDots[index] ? 'true' : 'false'
}
key={label}
onClick={label === 'Zoom' ? handleZoomClick : undefined}
ref={setOriginalRef(index)}
type="button"
>
<Glyph />
</Dot>
),
)}
<FlyingTrafficLights
onCatchDot={handleCatchDot}
onPopAnimationEnd={handlePopAnimationEnd}
portalReady={portalReady}
returningDots={returningDots}
returnedDots={returnedDots}
setFlyingRef={setFlyingRef}
visible={isEscaping}
/>
</Container>
);
}

View File

@@ -0,0 +1,61 @@
import { styled } from '@linaria/react';
import type React from 'react';
import { APP_PREVIEW_STAGE } from '@/tokens/app-preview/app-preview-stage';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { TrafficLights } from './TrafficLights';
const stage = APP_PREVIEW_STAGE.windowBar;
const BarRoot = styled.div<{ $grabbable?: boolean; $isDragging?: boolean }>`
align-items: center;
background: ${stage.background};
border-bottom: 1px solid ${stage.border};
box-shadow: inset 0 1px 0 ${stage.highlight};
cursor: ${({ $grabbable, $isDragging }) =>
$grabbable ? ($isDragging ? 'grabbing' : 'grab') : 'default'};
display: grid;
flex-shrink: 0;
grid-template-columns: auto 1fr auto;
padding: ${stage.verticalPaddingPx}px ${stage.horizontalPaddingPx}px;
user-select: none;
width: 100%;
`;
const Title = styled.span`
color: ${stage.titleColor};
font-family: var(--font-product), sans-serif;
font-size: 12px;
font-weight: ${THEME_LIGHT.font.weight.medium};
justify-self: center;
letter-spacing: 0.1px;
text-align: center;
`;
const RightSpacer = styled.div`
width: ${stage.trafficLightSlotWidthPx}px;
`;
export function WindowBar({
isDragging = false,
onDragStart,
title = 'Twenty',
}: {
isDragging?: boolean;
onDragStart?: (event: React.PointerEvent<HTMLDivElement>) => void;
title?: string;
}) {
return (
<BarRoot
$grabbable={onDragStart !== undefined}
$isDragging={isDragging}
onPointerDown={onDragStart}
>
<TrafficLights />
<Title>{title}</Title>
{/* Mirrors the controls width so the centered title does not drift. */}
<RightSpacer aria-hidden />
</BarRoot>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import { useMemo, useState, type ReactNode } from 'react';
import { WINDOW_ORDER_CONTEXTS } from './window-order-contexts';
// The desktop's z-order: last-activated window sits on top.
export function WindowOrderProvider({ children }: { children: ReactNode }) {
const [stack, setStack] = useState<string[]>([]);
const api = useMemo(
() => ({
register: (id: string) =>
setStack((previous) =>
previous.includes(id) ? previous : [...previous, id],
),
unregister: (id: string) =>
setStack((previous) => previous.filter((item) => item !== id)),
activate: (id: string) =>
setStack((previous) => {
if (previous[previous.length - 1] === id) {
return previous;
}
return [...previous.filter((item) => item !== id), id];
}),
}),
[],
);
return (
<WINDOW_ORDER_CONTEXTS.api.Provider value={api}>
<WINDOW_ORDER_CONTEXTS.stack.Provider value={stack}>
{children}
</WINDOW_ORDER_CONTEXTS.stack.Provider>
</WINDOW_ORDER_CONTEXTS.api.Provider>
);
}

View File

@@ -0,0 +1,73 @@
import { createTimeoutRegistry } from './timeout-registry';
describe('createTimeoutRegistry', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should track and run scheduled callbacks once', () => {
const registry = createTimeoutRegistry();
const callback = jest.fn();
registry.schedule(callback, 200);
expect(registry.pendingCount()).toBe(1);
jest.advanceTimersByTime(200);
expect(callback).toHaveBeenCalledTimes(1);
expect(registry.pendingCount()).toBe(0);
});
it('should cancel one scheduled callback without clearing unrelated callbacks', () => {
const registry = createTimeoutRegistry();
const firstCallback = jest.fn();
const secondCallback = jest.fn();
const cancelFirst = registry.schedule(firstCallback, 200);
registry.schedule(secondCallback, 300);
cancelFirst();
expect(registry.pendingCount()).toBe(1);
jest.advanceTimersByTime(300);
expect(firstCallback).not.toHaveBeenCalled();
expect(secondCallback).toHaveBeenCalledTimes(1);
expect(registry.pendingCount()).toBe(0);
});
it('should ignore double-cancels after the callback already ran', () => {
const registry = createTimeoutRegistry();
const callback = jest.fn();
const cancel = registry.schedule(callback, 100);
jest.advanceTimersByTime(100);
cancel();
cancel();
expect(callback).toHaveBeenCalledTimes(1);
expect(registry.pendingCount()).toBe(0);
});
it('should clear every pending timeout', () => {
const registry = createTimeoutRegistry();
const firstCallback = jest.fn();
const secondCallback = jest.fn();
registry.schedule(firstCallback, 200);
registry.schedule(secondCallback, 300);
registry.clearAll();
expect(registry.pendingCount()).toBe(0);
jest.advanceTimersByTime(500);
expect(firstCallback).not.toHaveBeenCalled();
expect(secondCallback).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
// Tracks scheduled timeouts so owners can cancel them all on teardown
// (every conversation beat schedules through one of these). Ported.
export type TimeoutRegistry = {
clearAll: () => void;
pendingCount: () => number;
schedule: (callback: () => void, delayMs: number) => () => void;
};
export function createTimeoutRegistry(): TimeoutRegistry {
const handles = new Set<ReturnType<typeof setTimeout>>();
const cancel = (handle: ReturnType<typeof setTimeout>) => {
if (!handles.delete(handle)) {
return;
}
clearTimeout(handle);
};
return {
clearAll: () => {
[...handles].forEach(cancel);
},
pendingCount: () => handles.size,
schedule: (callback, delayMs) => {
const handle = setTimeout(() => {
handles.delete(handle);
callback();
}, delayMs);
handles.add(handle);
return () => cancel(handle);
},
};
}

View File

@@ -0,0 +1,89 @@
import {
trafficLightPhysics,
type TrafficLightPhysicsState,
} from './traffic-light-physics';
const createRandomSequence = (values: number[]) => {
let index = 0;
return () => values[index++] ?? values[values.length - 1] ?? 0.5;
};
describe('trafficLightPhysics', () => {
it('should create deterministic launch state from an element origin', () => {
expect(
trafficLightPhysics.createState(
{ left: 12, top: 24 },
createRandomSequence([0.5, 0.5, 0.5]),
),
).toEqual({
angularVelocity: 0,
isResting: false,
rotation: 0,
vx: 0,
vy: -6.5,
x: 12,
y: 24,
});
});
it('should mark a dot as resting when the floor impact is below the rest threshold', () => {
const state: TrafficLightPhysicsState = {
angularVelocity: 1,
isResting: false,
rotation: 0,
vx: 0.1,
vy: 0.1,
x: 0,
y: 100,
};
trafficLightPhysics.step(state, { floor: 100, rightWall: 500 });
expect(state).toMatchObject({
angularVelocity: 0,
isResting: true,
vx: 0,
vy: 0,
y: 100,
});
});
it('should clamp side-wall collisions and reflect horizontal velocity', () => {
const state: TrafficLightPhysicsState = {
angularVelocity: 0,
isResting: false,
rotation: 0,
vx: 10,
vy: -1,
x: 98,
y: 0,
};
trafficLightPhysics.step(state, { floor: 500, rightWall: 100 });
expect(state.x).toBe(100);
expect(state.vx).toBeLessThan(0);
});
it('should apply a scroll disturbance and clear resting state', () => {
const state: TrafficLightPhysicsState = {
angularVelocity: 0,
isResting: true,
rotation: 0,
vx: 1,
vy: 0,
x: 0,
y: 100,
};
trafficLightPhysics.disturb(state, createRandomSequence([0, 1, 0.5]));
expect(state).toMatchObject({
angularVelocity: 0,
isResting: false,
vx: 2.5,
vy: -7,
});
});
});

View File

@@ -0,0 +1,111 @@
// The escaped dots' bounce physics (verbatim port). Velocity randomness
// is injectable for the jest suite.
const GRAVITY = 0.55;
const BOUNCE_DAMPING = 0.4;
const AIR_FRICTION = 0.915;
const GROUND_FRICTION = 0.32;
const REST_VELOCITY = 0.9;
const INITIAL_POP_MIN = -5;
const INITIAL_POP_MAX = -8;
const INITIAL_HORIZONTAL_RANGE = 3;
const INITIAL_SPIN = 4;
const SCROLL_IMPULSE_MIN = 7;
const SCROLL_IMPULSE_MAX = 12;
const SCROLL_HORIZONTAL_RANGE = 1.5;
export type TrafficLightPhysicsState = {
x: number;
y: number;
vx: number;
vy: number;
rotation: number;
angularVelocity: number;
isResting: boolean;
};
type TrafficLightPhysicsOrigin = {
left: number;
top: number;
};
type TrafficLightPhysicsBounds = {
floor: number;
rightWall: number;
};
const createState = (
origin: TrafficLightPhysicsOrigin | null | undefined,
random: () => number = Math.random,
): TrafficLightPhysicsState => ({
x: origin?.left ?? 0,
y: origin?.top ?? 0,
vx: (random() - 0.5) * 2 * INITIAL_HORIZONTAL_RANGE,
vy: INITIAL_POP_MIN + random() * (INITIAL_POP_MAX - INITIAL_POP_MIN),
rotation: 0,
angularVelocity: (random() - 0.5) * 2 * INITIAL_SPIN,
isResting: false,
});
const step = (
state: TrafficLightPhysicsState,
bounds: TrafficLightPhysicsBounds,
) => {
if (state.isResting) {
return state;
}
state.vy += GRAVITY;
state.vx *= AIR_FRICTION;
state.x += state.vx;
state.y += state.vy;
state.rotation += state.angularVelocity;
if (state.y >= bounds.floor) {
state.y = bounds.floor;
state.vx *= GROUND_FRICTION;
if (Math.abs(state.vy) < REST_VELOCITY) {
state.vy = 0;
state.angularVelocity = 0;
if (Math.abs(state.vx) < 0.3) {
state.vx = 0;
state.isResting = true;
}
} else {
state.vy = -state.vy * BOUNCE_DAMPING;
state.angularVelocity *= 0.7;
}
}
if (state.x < 0) {
state.x = 0;
state.vx = -state.vx * BOUNCE_DAMPING;
} else if (state.x > bounds.rightWall) {
state.x = bounds.rightWall;
state.vx = -state.vx * BOUNCE_DAMPING;
}
return state;
};
const disturb = (
state: TrafficLightPhysicsState,
random: () => number = Math.random,
) => {
state.isResting = false;
state.vy = -(
SCROLL_IMPULSE_MIN +
random() * (SCROLL_IMPULSE_MAX - SCROLL_IMPULSE_MIN)
);
state.vx += (random() - 0.5) * 2 * SCROLL_HORIZONTAL_RANGE;
state.angularVelocity += (random() - 0.5) * 8;
return state;
};
export const trafficLightPhysics = {
createState,
disturb,
step,
};

View File

@@ -0,0 +1,4 @@
// The prompt egg (terminal subtree) launches the traffic-light escape
// (stage subtree); the window event is the old site's bridge between
// them, ported as-is.
export const TRAFFIC_LIGHTS_ESCAPE_EVENT = 'twenty-traffic-lights-escape';

View File

@@ -0,0 +1,22 @@
'use client';
import { useEffect, useRef } from 'react';
import {
createTimeoutRegistry,
type TimeoutRegistry,
} from './timeout-registry';
export function useTimeoutRegistry(): TimeoutRegistry {
const registryReference = useRef<TimeoutRegistry | null>(null);
registryReference.current ??= createTimeoutRegistry();
useEffect(() => {
const registry = registryReference.current;
return () => {
registry?.clearAll();
};
}, []);
return registryReference.current;
}

View File

@@ -0,0 +1,197 @@
'use client';
import {
useCallback,
useEffect,
useRef,
useState,
type RefCallback,
} from 'react';
import { createAnimationFrameLoop } from '@/platform/motion';
import {
trafficLightPhysics,
type TrafficLightPhysicsState,
} from './traffic-light-physics';
import { TRAFFIC_LIGHTS_ESCAPE_EVENT } from './traffic-lights-escape-event';
const TRAFFIC_LIGHT_DOT_SIZE = 12;
const TRAFFIC_LIGHT_COUNT = 3;
const FLOOR_PADDING = 0;
const createReturnState = (): boolean[] =>
Array.from({ length: TRAFFIC_LIGHT_COUNT }, () => false);
// The escape egg, ported as-is: the prompt egg's window event launches
// the dots out of the bar; they bounce to the page floor under gravity,
// get disturbed by scroll, and return when caught.
export const useTrafficLightsEscape = () => {
const [isEscaping, setIsEscaping] = useState(false);
const [returningDots, setReturningDots] = useState(createReturnState);
const [returnedDots, setReturnedDots] = useState(createReturnState);
const [portalReady, setPortalReady] = useState(false);
const originalRefs = useRef<Array<HTMLElement | null>>([null, null, null]);
const flyingRefs = useRef<Array<HTMLButtonElement | null>>([
null,
null,
null,
]);
const physicsRef = useRef<TrafficLightPhysicsState[]>([]);
useEffect(() => {
setPortalReady(true);
}, []);
const escape = useCallback(() => {
physicsRef.current = originalRefs.current.map((element) => {
const rect = element?.getBoundingClientRect();
return trafficLightPhysics.createState(rect);
});
flyingRefs.current.forEach((element) => {
element?.removeAttribute('data-resting');
});
setReturningDots(createReturnState());
setReturnedDots(createReturnState());
setIsEscaping(true);
}, []);
useEffect(() => {
window.addEventListener(TRAFFIC_LIGHTS_ESCAPE_EVENT, escape);
return () => {
window.removeEventListener(TRAFFIC_LIGHTS_ESCAPE_EVENT, escape);
};
}, [escape]);
useEffect(() => {
if (!isEscaping) {
return;
}
const physicsLoop = createAnimationFrameLoop({
onFrame: () => {
const floor =
window.innerHeight - TRAFFIC_LIGHT_DOT_SIZE - FLOOR_PADDING;
const rightWall = window.innerWidth - TRAFFIC_LIGHT_DOT_SIZE;
physicsRef.current.forEach((physicsState, index) => {
const element = flyingRefs.current[index];
if (!element) {
return;
}
const wasResting = physicsState.isResting;
trafficLightPhysics.step(physicsState, { floor, rightWall });
if (!wasResting && physicsState.isResting) {
element.setAttribute('data-resting', 'true');
}
element.style.transform = `translate(${physicsState.x}px, ${physicsState.y}px) rotate(${physicsState.rotation}deg)`;
});
return !physicsRef.current.every(
(physicsState, index) =>
physicsState.isResting || !flyingRefs.current[index],
);
},
});
physicsLoop.start();
const handleScroll = () => {
let anyDisturbed = false;
physicsRef.current.forEach((physicsState, index) => {
const element = flyingRefs.current[index];
if (!element) {
return;
}
trafficLightPhysics.disturb(physicsState);
element.removeAttribute('data-resting');
anyDisturbed = true;
});
if (anyDisturbed) {
physicsLoop.start();
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
physicsLoop.stop();
window.removeEventListener('scroll', handleScroll);
};
}, [isEscaping]);
const handleCatchDot = useCallback((index: number) => {
setReturningDots((previous) => {
if (previous[index]) {
return previous;
}
const next = [...previous];
next[index] = true;
return next;
});
}, []);
const handlePopAnimationEnd = useCallback((index: number) => {
setReturnedDots((previous) => {
if (previous[index]) {
return previous;
}
const next = [...previous];
next[index] = true;
return next;
});
}, []);
const setOriginalRef = useCallback(
(index: number): RefCallback<HTMLElement> =>
(element) => {
originalRefs.current[index] = element;
},
[],
);
const setFlyingRef = useCallback(
(index: number): RefCallback<HTMLButtonElement> =>
(element) => {
flyingRefs.current[index] = element;
if (!element) {
return;
}
const physicsState = physicsRef.current[index];
if (physicsState) {
element.style.transform = `translate(${physicsState.x}px, ${physicsState.y}px)`;
}
},
[],
);
return {
escape,
handleCatchDot,
handlePopAnimationEnd,
isEscaping,
portalReady,
returnedDots,
returningDots,
setFlyingRef,
setOriginalRef,
};
};

View File

@@ -0,0 +1,31 @@
'use client';
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { WINDOW_ORDER_CONTEXTS } from './window-order-contexts';
export function useWindowOrder(id: string) {
const api = useContext(WINDOW_ORDER_CONTEXTS.api);
const stack = useContext(WINDOW_ORDER_CONTEXTS.stack);
useEffect(() => {
if (!api) {
return undefined;
}
api.register(id);
return () => {
api.unregister(id);
};
}, [api, id]);
const zIndex = useMemo(() => {
const index = stack.indexOf(id);
return index === -1 ? 1 : index + 2;
}, [stack, id]);
const activate = useCallback(() => {
api?.activate(id);
}, [api, id]);
return { activate, zIndex };
}

Some files were not shown because too many files have changed in this diff Show More