Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code 45e3da0527 Microsoft email folder filters ignored during sync
https://sonarly.com/issue/26908?type=bug

When users select "Some folders" and pick specific subfolders for Microsoft/Outlook email sync, ALL emails are imported because the backend only discovers top-level mail folders from the Microsoft Graph API and never finds the user-selected child folders.

Fix: Added recursive child folder discovery to MicrosoftGetAllFoldersService. The existing code only called /me/mailFolders which returns top-level Microsoft mail folders. When a user selected "Some folders" and picked a subfolder (e.g., "Inbox/Twenty"), that subfolder was never discovered by the backend, so it could never have isSynced=true, and the isSynced filter in the message list service would find zero matching folders.

The fix adds a private fetchFoldersRecursively method that checks each folder's childFolderCount (already present in the Microsoft Graph API response and already typed in MicrosoftGraphFolder). For folders with children, it calls /me/mailFolders/{id}/childFolders to fetch nested folders, and recurses for any deeper nesting. The error handling follows the same .catch() pattern used for the top-level folder fetch — errors are logged and return empty results rather than breaking the entire sync.

The downstream isSynced filtering in MicrosoftGetMessageListService (line 41-44) already correctly filters folders when SELECTED_FOLDERS policy is active, and the early-exit guard (line 47) already returns empty when no folders match. The only missing piece was that child folders were never discovered in the first place.
2026-04-16 07:22:32 +00:00
6101a4f113 Update website hero to use shared local avatars and logos (#19736)
## Summary
- import shared company logos and people avatars into
`packages/twenty-website-new/public/images/shared`
- expand the shared asset registry with the new local logo and avatar
paths
- update the home hero data to use local shared assets for matched
people and company icons
- replace synthetic or mismatched hero avatars with better-fitting named
or anonymous local assets
- prefer local shared company logos in hero visual components before
falling back to remote icons

## Testing
- Not run (not requested)

---------

Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
2026-04-16 06:58:48 +00:00
Thomas des FrancsandGitHub da8fe7f6f7 Homepage 3 cards finished (#19732)
& various fixes
2026-04-16 06:42:25 +00:00
109 changed files with 5320 additions and 1036 deletions
@@ -17,7 +17,6 @@ import {
type ListLayerVersionsCommandInput,
LogType,
PublishLayerVersionCommand,
ResourceConflictException,
ResourceNotFoundException,
UpdateFunctionConfigurationCommand,
waitUntilFunctionActiveV2,
@@ -984,7 +983,6 @@ export class LambdaDriver implements LogicFunctionDriver {
const canSkip =
isDefined(lambdaExecutor) &&
lambdaExecutor.Configuration?.State === 'Active' &&
!flatApplication.isSdkLayerStale &&
this.hasExpectedLayers({
lambdaExecutor,
@@ -1200,17 +1198,6 @@ export class LambdaDriver implements LogicFunctionDriver {
);
}
if (error instanceof ResourceConflictException) {
this.logger.warn(
`Lambda function '${flatLogicFunction.id}' is not yet active (state conflict). Retrying after waiting for active state.`,
);
throw new LogicFunctionException(
`Function '${flatLogicFunction.id}' is not ready for invocation (currently updating or pending)`,
LogicFunctionExceptionCode.LOGIC_FUNCTION_EXECUTION_FAILED,
);
}
if (error instanceof LogicFunctionException) {
throw error;
}
@@ -62,11 +62,19 @@ export class MicrosoftGetAllFoldersService implements MessageFolderDriver {
return { value: [] };
});
const folders = (response.value as MicrosoftGraphFolder[]) || [];
const rootFolderId = this.getRootFolderId(folders);
const topLevelFolders =
(response.value as MicrosoftGraphFolder[]) || [];
const allFolders = await this.fetchFoldersRecursively(
microsoftClient,
connectedAccount,
topLevelFolders,
);
const rootFolderId = this.getRootFolderId(topLevelFolders);
const folderInfos: DiscoveredMessageFolder[] = [];
for (const folder of folders) {
for (const folder of allFolders) {
if (!folder.displayName) {
continue;
}
@@ -111,6 +119,44 @@ export class MicrosoftGetAllFoldersService implements MessageFolderDriver {
}
}
private async fetchFoldersRecursively(
microsoftClient: any,
connectedAccount: Pick<ConnectedAccountEntity, 'id'>,
folders: MicrosoftGraphFolder[],
): Promise<MicrosoftGraphFolder[]> {
const allFolders: MicrosoftGraphFolder[] = [...folders];
for (const folder of folders) {
if (folder.childFolderCount && folder.childFolderCount > 0) {
const childResponse = await microsoftClient
.api(`/me/mailFolders/${folder.id}/childFolders`)
.version('beta')
.top(MESSAGING_MICROSOFT_MAIL_FOLDERS_LIST_MAX_RESULT)
.get()
.catch((error: Error) => {
this.logger.error(
`Connected account ${connectedAccount.id}: Error fetching child folders for ${folder.id}: ${error.message}`,
);
return { value: [] };
});
const childFolders =
(childResponse.value as MicrosoftGraphFolder[]) || [];
const nestedFolders = await this.fetchFoldersRecursively(
microsoftClient,
connectedAccount,
childFolders,
);
allFolders.push(...nestedFolders);
}
}
return allFolders;
}
private isSentFolder(standardFolder: StandardFolder | null): boolean {
return standardFolder === StandardFolder.SENT;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<rect width="16" height="16" rx="2" fill="#000" />
<path
d="M2.147 9.383c-.027-.114.109-.186.192-.103L6.72 13.66c.083.083.011.219-.103.192a6.015 6.015 0 0 1-4.47-4.47ZM2 7.627a.119.119 0 0 0 .035.091l6.247 6.247a.119.119 0 0 0 .091.035c.285-.018.564-.055.836-.111a.117.117 0 0 0 .057-.198L2.31 6.734a.117.117 0 0 0-.198.057 6.007 6.007 0 0 0-.11.836ZM2.505 5.565a.119.119 0 0 0 .025.132l7.773 7.773a.118.118 0 0 0 .132.025c.215-.096.422-.203.623-.322a.118.118 0 0 0 .022-.185L3.012 4.92a.118.118 0 0 0-.185.022c-.119.2-.226.408-.322.623ZM3.519 4.169a.118.118 0 0 1-.005-.163 6.006 6.006 0 1 1 8.48 8.48.118.118 0 0 1-.163-.005L3.52 4.169Z"
fill="#FFF"
/>
</svg>

After

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1003 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
>
<rect width="40" height="40" rx="8" fill="#000000" />
<path
d="M6.822 14.174c0-2.435 1.996-4.411 4.456-4.411h8.574c.125 0 .241.075.293.19a.31.31 0 0 1-.056.344l-1.88 2.023c-.326.35-.787.552-1.27.552H11.3c-.738 0-1.338.594-1.338 1.325v3.336a.78.78 0 0 1-.783.777H7.61a.78.78 0 0 1-.783-.777v-3.36zM33.5 25.553c0 2.434-1.996 4.411-4.456 4.411h-3.642c-2.46 0-4.454-1.977-4.454-4.411v-6.315c0-.43.16-.842.456-1.16l2.124-2.285a.33.33 0 0 1 .355-.081.32.32 0 0 1 .205.295v9.527c0 .73.598 1.322 1.337 1.322h3.6a1.33 1.33 0 0 0 1.337-1.322V14.197c0-.73-.599-1.325-1.337-1.325H24.84c-.481 0-.938.201-1.265.547L11.088 26.856h7.503a.78.78 0 0 1 .784.778v1.552a.78.78 0 0 1-.784.778H8.481a1.655 1.655 0 0 1-1.662-1.644v-.824c0-.412.156-.809.44-1.114l13.999-15.06a4.9 4.9 0 0 1 3.594-1.56h4.189c2.46 0 4.454 1.977 4.454 4.412v11.379z"
fill="#FFFFFF"
/>
</svg>

After

Width:  |  Height:  |  Size: 968 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -8,15 +8,32 @@ import type {
import { SHARED_PEOPLE_AVATAR_URLS } from '@/lib/shared-asset-paths';
const PEOPLE_AVATAR_URLS = {
craigFederighi:
'https://twentyhq.github.io/placeholder-images/people/image-33.png',
eddyCue: 'https://twentyhq.github.io/placeholder-images/people/image-18.png',
jeffWilliams:
'https://twentyhq.github.io/placeholder-images/people/image-22.png',
anonymousIndira: SHARED_PEOPLE_AVATAR_URLS.anonymousIndira,
anonymousLaura: SHARED_PEOPLE_AVATAR_URLS.anonymousLaura,
brianChesky: SHARED_PEOPLE_AVATAR_URLS.brianChesky,
benChestnut: SHARED_PEOPLE_AVATAR_URLS.benChestnut,
chrisWanstrath: SHARED_PEOPLE_AVATAR_URLS.chrisWanstrath,
craigFederighi: SHARED_PEOPLE_AVATAR_URLS.craigFederighi,
darioAmodei: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
dylanField: SHARED_PEOPLE_AVATAR_URLS.dylanField,
eddyCue: SHARED_PEOPLE_AVATAR_URLS.eddyCue,
ivanZhao: SHARED_PEOPLE_AVATAR_URLS.ivanZhao,
jeffWilliams: SHARED_PEOPLE_AVATAR_URLS.jeffWilliams,
joeGebbia: SHARED_PEOPLE_AVATAR_URLS.joeGebbia,
katherineAdams: SHARED_PEOPLE_AVATAR_URLS.katherineAdams,
philSchiller:
'https://twentyhq.github.io/placeholder-images/people/image-14.png',
timCook: 'https://twentyhq.github.io/placeholder-images/people/image-27.png',
patrickCollison: SHARED_PEOPLE_AVATAR_URLS.patrickCollison,
pingLi: SHARED_PEOPLE_AVATAR_URLS.pingLi,
peterReinhardt: SHARED_PEOPLE_AVATAR_URLS.peterReinhardt,
peterThiel: SHARED_PEOPLE_AVATAR_URLS.peterThiel,
philSchiller: SHARED_PEOPLE_AVATAR_URLS.philSchiller,
rayDamm: SHARED_PEOPLE_AVATAR_URLS.rayDamm,
reidHoffman: SHARED_PEOPLE_AVATAR_URLS.reidHoffman,
roelofBotha: SHARED_PEOPLE_AVATAR_URLS.roelofBotha,
ryanRoslansky: SHARED_PEOPLE_AVATAR_URLS.ryanRoslansky,
stewartButterfield: SHARED_PEOPLE_AVATAR_URLS.stewartButterfield,
sundarPichai: SHARED_PEOPLE_AVATAR_URLS.sundarPichai,
thomasDohmke: SHARED_PEOPLE_AVATAR_URLS.thomasDohmke,
timCook: SHARED_PEOPLE_AVATAR_URLS.timCook,
} as const;
const SALES_DASHBOARD_DATA: HeroDashboardDataType = {
@@ -87,10 +104,10 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
},
accountOwner: {
type: 'person',
name: 'Phil Schiller',
tone: 'amber',
name: 'Dario Amodei',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.philSchiller,
avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
},
rating: 2,
date: 'Jul 1, 2023',
@@ -100,8 +117,7 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
shortLabel: 'D',
tone: 'gray',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-11.png',
avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
},
recordId: 'OPP-1',
},
@@ -112,10 +128,10 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
company: { type: 'entity', name: 'Figma', domain: 'figma.com' },
accountOwner: {
type: 'person',
name: 'Tim Cook',
tone: 'teal',
name: 'Dylan Field',
tone: 'purple',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.timCook,
avatarUrl: PEOPLE_AVATAR_URLS.dylanField,
},
rating: 2,
date: 'Jul 12, 2023',
@@ -125,8 +141,7 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
shortLabel: 'D',
tone: 'purple',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-31.png',
avatarUrl: PEOPLE_AVATAR_URLS.dylanField,
},
recordId: 'OPP-2',
},
@@ -144,10 +159,10 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
company: { type: 'entity', name: 'Notion', domain: 'notion.com' },
accountOwner: {
type: 'person',
name: 'Phil Schiller',
tone: 'amber',
name: 'Ivan Zhao',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.philSchiller,
avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
},
rating: 4,
date: 'Jul 8, 2023',
@@ -157,8 +172,7 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
shortLabel: 'I',
tone: 'gray',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-09.png',
avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
},
recordId: 'OPP-3',
},
@@ -176,10 +190,10 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
company: { type: 'entity', name: 'Github', domain: 'github.com' },
accountOwner: {
type: 'person',
name: 'Jeff Williams',
tone: 'blue',
name: 'Chris Wanstrath',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.jeffWilliams,
avatarUrl: PEOPLE_AVATAR_URLS.chrisWanstrath,
},
rating: 3,
date: 'Jul 14, 2023',
@@ -189,8 +203,7 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
shortLabel: 'T',
tone: 'gray',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-24.png',
avatarUrl: PEOPLE_AVATAR_URLS.thomasDohmke,
},
recordId: 'OPP-4',
},
@@ -201,10 +214,10 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
company: { type: 'entity', name: 'Stripe', domain: 'stripe.com' },
accountOwner: {
type: 'person',
name: 'Tim Cook',
tone: 'teal',
name: 'Patrick Collison',
tone: 'blue',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.timCook,
avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison,
},
rating: 5,
date: 'Jul 17, 2023',
@@ -214,8 +227,7 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
shortLabel: 'P',
tone: 'blue',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-16.png',
avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison,
},
recordId: 'OPP-5',
},
@@ -226,10 +238,10 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
company: { type: 'entity', name: 'Airbnb', domain: 'airbnb.com' },
accountOwner: {
type: 'person',
name: 'Eddy Cue',
tone: 'gray',
name: 'Joe Gebbia',
tone: 'pink',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.eddyCue,
avatarUrl: PEOPLE_AVATAR_URLS.joeGebbia,
},
rating: 3,
date: 'Jul 15, 2023',
@@ -239,8 +251,7 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
shortLabel: 'B',
tone: 'pink',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-01.png',
avatarUrl: PEOPLE_AVATAR_URLS.brianChesky,
},
recordId: 'OPP-6',
},
@@ -268,10 +279,10 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
},
accountOwner: {
type: 'person',
name: 'Craig Federighi',
tone: 'purple',
name: 'Ben Chestnut',
tone: 'amber',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.craigFederighi,
avatarUrl: PEOPLE_AVATAR_URLS.benChestnut,
},
rating: 4,
date: 'Jul 23, 2023',
@@ -281,8 +292,7 @@ const OPPORTUNITY_KANBAN_PAGE: HeroKanbanPageDefinition = {
shortLabel: 'R',
tone: 'amber',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-28.png',
avatarUrl: PEOPLE_AVATAR_URLS.anonymousLaura,
},
recordId: 'OPP-7',
},
@@ -373,21 +383,21 @@ export const HERO_DATA: HeroHomeDataType = {
name: 'Anthropic',
domain: 'anthropic.com',
},
url: { type: 'link', value: 'qonto.com' },
url: { type: 'link', value: 'anthropic.com' },
createdBy: {
type: 'person',
name: 'Jeff Williams',
tone: 'blue',
name: 'Dario Amodei',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.jeffWilliams,
avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
},
address: { type: 'text', value: '18 Rue De Navarin' },
accountOwner: {
type: 'person',
name: 'Phil Schiller',
tone: 'amber',
name: 'Dario Amodei',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.philSchiller,
avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
},
icp: { type: 'boolean', value: true },
arr: { type: 'number', value: '$500,000' },
@@ -399,8 +409,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'D',
tone: 'gray',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-11.png',
avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
},
employees: { type: 'number', value: '612' },
opportunities: {
@@ -427,18 +436,18 @@ export const HERO_DATA: HeroHomeDataType = {
url: { type: 'link', value: 'linkedin.com' },
createdBy: {
type: 'person',
name: 'Craig Federighi',
name: 'Reid Hoffman',
tone: 'purple',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.craigFederighi,
avatarUrl: PEOPLE_AVATAR_URLS.reidHoffman,
},
address: { type: 'text', value: '1226 Moises Causeway' },
accountOwner: {
type: 'person',
name: 'Craig Federighi',
tone: 'purple',
name: 'Ryan Roslansky',
tone: 'teal',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.craigFederighi,
avatarUrl: PEOPLE_AVATAR_URLS.ryanRoslansky,
},
icp: { type: 'boolean', value: false },
arr: { type: 'number', value: '$1,000,000' },
@@ -450,8 +459,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'R',
tone: 'teal',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-26.png',
avatarUrl: PEOPLE_AVATAR_URLS.ryanRoslansky,
},
employees: { type: 'number', value: '19,300' },
opportunities: {
@@ -474,18 +482,18 @@ export const HERO_DATA: HeroHomeDataType = {
url: { type: 'link', value: 'slack.com' },
createdBy: {
type: 'person',
name: 'Eddy Cue',
tone: 'gray',
name: 'Stewart Butterfield',
tone: 'teal',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.eddyCue,
avatarUrl: PEOPLE_AVATAR_URLS.stewartButterfield,
},
address: { type: 'text', value: '1316 Dameon Mountain' },
accountOwner: {
type: 'person',
name: 'Katherine Adams',
tone: 'red',
name: 'Stewart Butterfield',
tone: 'teal',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.katherineAdams,
avatarUrl: PEOPLE_AVATAR_URLS.stewartButterfield,
},
icp: { type: 'boolean', value: true },
arr: { type: 'number', value: '$2,300,000' },
@@ -497,8 +505,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'LJ',
tone: 'pink',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-04.png',
avatarUrl: PEOPLE_AVATAR_URLS.anonymousIndira,
},
employees: { type: 'number', value: '4,500' },
opportunities: {
@@ -533,10 +540,10 @@ export const HERO_DATA: HeroHomeDataType = {
address: { type: 'text', value: '1162 Sammy Creek' },
accountOwner: {
type: 'person',
name: 'Phil Schiller',
tone: 'amber',
name: 'Ivan Zhao',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.philSchiller,
avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
},
icp: { type: 'boolean', value: false },
arr: { type: 'number', value: '$750,000' },
@@ -548,8 +555,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'I',
tone: 'gray',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-09.png',
avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
},
employees: { type: 'number', value: '620' },
opportunities: {
@@ -580,10 +586,10 @@ export const HERO_DATA: HeroHomeDataType = {
address: { type: 'text', value: '110 Oswald Junction' },
accountOwner: {
type: 'person',
name: 'Tim Cook',
tone: 'teal',
name: 'Dylan Field',
tone: 'purple',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.timCook,
avatarUrl: PEOPLE_AVATAR_URLS.dylanField,
},
icp: { type: 'boolean', value: true },
arr: { type: 'number', value: '$3,500,000' },
@@ -595,8 +601,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'D',
tone: 'purple',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-31.png',
avatarUrl: PEOPLE_AVATAR_URLS.dylanField,
},
employees: { type: 'number', value: '1,300' },
opportunities: {
@@ -624,18 +629,18 @@ export const HERO_DATA: HeroHomeDataType = {
url: { type: 'link', value: 'github.com' },
createdBy: {
type: 'person',
name: 'Jeff Williams',
tone: 'blue',
name: 'Chris Wanstrath',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.jeffWilliams,
avatarUrl: PEOPLE_AVATAR_URLS.chrisWanstrath,
},
address: { type: 'text', value: '3891 Ranchview Drive' },
accountOwner: {
type: 'person',
name: 'Jeff Williams',
tone: 'blue',
name: 'Thomas Dohmke',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.jeffWilliams,
avatarUrl: PEOPLE_AVATAR_URLS.thomasDohmke,
},
icp: { type: 'boolean', value: true },
arr: { type: 'number', value: '$900,000' },
@@ -647,8 +652,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'T',
tone: 'gray',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-24.png',
avatarUrl: PEOPLE_AVATAR_URLS.thomasDohmke,
},
employees: { type: 'number', value: '3,800' },
opportunities: {
@@ -671,18 +675,18 @@ export const HERO_DATA: HeroHomeDataType = {
url: { type: 'link', value: 'airbnb.com' },
createdBy: {
type: 'person',
name: 'Tim Cook',
tone: 'teal',
name: 'Joe Gebbia',
tone: 'pink',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.timCook,
avatarUrl: PEOPLE_AVATAR_URLS.joeGebbia,
},
address: { type: 'text', value: '4517 Washington Avenue' },
accountOwner: {
type: 'person',
name: 'Eddy Cue',
tone: 'gray',
name: 'Brian Chesky',
tone: 'pink',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.eddyCue,
avatarUrl: PEOPLE_AVATAR_URLS.brianChesky,
},
icp: { type: 'boolean', value: true },
arr: { type: 'number', value: '$4,200,000' },
@@ -694,8 +698,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'B',
tone: 'pink',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-01.png',
avatarUrl: PEOPLE_AVATAR_URLS.brianChesky,
},
employees: { type: 'number', value: '6,900' },
opportunities: {
@@ -716,18 +719,18 @@ export const HERO_DATA: HeroHomeDataType = {
url: { type: 'link', value: 'stripe.com' },
createdBy: {
type: 'person',
name: 'Katherine Adams',
tone: 'red',
name: 'Patrick Collison',
tone: 'blue',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.katherineAdams,
avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison,
},
address: { type: 'text', value: '2118 Thornridge Circle' },
accountOwner: {
type: 'person',
name: 'Tim Cook',
tone: 'teal',
name: 'Patrick Collison',
tone: 'blue',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.timCook,
avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison,
},
icp: { type: 'boolean', value: true },
arr: { type: 'number', value: '$1,800,000' },
@@ -739,8 +742,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'P',
tone: 'blue',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-16.png',
avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison,
},
employees: { type: 'number', value: '7,400' },
opportunities: {
@@ -767,18 +769,18 @@ export const HERO_DATA: HeroHomeDataType = {
url: { type: 'link', value: 'sequoia.com' },
createdBy: {
type: 'person',
name: 'Phil Schiller',
tone: 'amber',
name: 'Roelof Botha',
tone: 'green',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.philSchiller,
avatarUrl: PEOPLE_AVATAR_URLS.roelofBotha,
},
address: { type: 'text', value: '1316 Dameon Mountain' },
accountOwner: {
type: 'person',
name: 'Phil Schiller',
tone: 'amber',
name: 'Roelof Botha',
tone: 'green',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.philSchiller,
avatarUrl: PEOPLE_AVATAR_URLS.roelofBotha,
},
icp: { type: 'boolean', value: false },
arr: { type: 'number', value: '$6,000,000' },
@@ -790,8 +792,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'R',
tone: 'green',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-13.png',
avatarUrl: PEOPLE_AVATAR_URLS.roelofBotha,
},
employees: { type: 'number', value: '1,100' },
opportunities: {
@@ -812,18 +813,18 @@ export const HERO_DATA: HeroHomeDataType = {
url: { type: 'link', value: 'segment.com' },
createdBy: {
type: 'person',
name: 'Phil Schiller',
tone: 'amber',
name: 'Peter Reinhardt',
tone: 'teal',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.philSchiller,
avatarUrl: PEOPLE_AVATAR_URLS.peterReinhardt,
},
address: { type: 'text', value: '8502 Preston Rd. East' },
accountOwner: {
type: 'person',
name: 'Eddy Cue',
tone: 'gray',
name: 'Peter Reinhardt',
tone: 'teal',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.eddyCue,
avatarUrl: PEOPLE_AVATAR_URLS.peterReinhardt,
},
icp: { type: 'boolean', value: true },
arr: { type: 'number', value: '$2,750,000' },
@@ -835,8 +836,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'P',
tone: 'teal',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-20.png',
avatarUrl: PEOPLE_AVATAR_URLS.peterReinhardt,
},
employees: { type: 'number', value: '1,550' },
opportunities: {
@@ -863,18 +863,18 @@ export const HERO_DATA: HeroHomeDataType = {
url: { type: 'link', value: 'mailchimp.com' },
createdBy: {
type: 'person',
name: 'Eddy Cue',
tone: 'gray',
name: 'Ben Chestnut',
tone: 'amber',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.eddyCue,
avatarUrl: PEOPLE_AVATAR_URLS.benChestnut,
},
address: { type: 'text', value: '3517 W. Gray St.' },
accountOwner: {
type: 'person',
name: 'Craig Federighi',
tone: 'purple',
name: 'Ben Chestnut',
tone: 'amber',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.craigFederighi,
avatarUrl: PEOPLE_AVATAR_URLS.benChestnut,
},
icp: { type: 'boolean', value: false },
arr: { type: 'number', value: '$1,250,000' },
@@ -886,8 +886,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'R',
tone: 'amber',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-28.png',
avatarUrl: PEOPLE_AVATAR_URLS.anonymousLaura,
},
employees: { type: 'number', value: '1,900' },
opportunities: {
@@ -910,18 +909,18 @@ export const HERO_DATA: HeroHomeDataType = {
url: { type: 'link', value: 'accel.com' },
createdBy: {
type: 'person',
name: 'Craig Federighi',
name: 'Ray Damm',
tone: 'purple',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.craigFederighi,
avatarUrl: PEOPLE_AVATAR_URLS.rayDamm,
},
address: { type: 'text', value: '4140 Parker Rd.' },
accountOwner: {
type: 'person',
name: 'Katherine Adams',
tone: 'red',
name: 'Ping Li',
tone: 'purple',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.katherineAdams,
avatarUrl: PEOPLE_AVATAR_URLS.pingLi,
},
icp: { type: 'boolean', value: true },
arr: { type: 'number', value: '$5,800,000' },
@@ -933,8 +932,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'P',
tone: 'purple',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-30.png',
avatarUrl: PEOPLE_AVATAR_URLS.pingLi,
},
employees: { type: 'number', value: '540' },
opportunities: {
@@ -965,10 +963,10 @@ export const HERO_DATA: HeroHomeDataType = {
address: { type: 'text', value: '2715 Ash Dr. San Jose' },
accountOwner: {
type: 'person',
name: 'Tim Cook',
tone: 'teal',
name: 'Peter Thiel',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.timCook,
avatarUrl: PEOPLE_AVATAR_URLS.peterThiel,
},
icp: { type: 'boolean', value: true },
arr: { type: 'number', value: '$2,100,000' },
@@ -980,8 +978,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'P',
tone: 'gray',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-05.png',
avatarUrl: PEOPLE_AVATAR_URLS.peterThiel,
},
employees: { type: 'number', value: '734' },
opportunities: {
@@ -1004,18 +1001,18 @@ export const HERO_DATA: HeroHomeDataType = {
url: { type: 'link', value: 'google.com' },
createdBy: {
type: 'person',
name: 'Tim Cook',
name: 'Sundar Pichai',
tone: 'teal',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.timCook,
avatarUrl: PEOPLE_AVATAR_URLS.sundarPichai,
},
address: { type: 'text', value: '4140 Parker Rd.' },
accountOwner: {
type: 'person',
name: 'Jeff Williams',
tone: 'blue',
name: 'Sundar Pichai',
tone: 'teal',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.jeffWilliams,
avatarUrl: PEOPLE_AVATAR_URLS.sundarPichai,
},
icp: { type: 'boolean', value: false },
arr: { type: 'number', value: '$7,500,000' },
@@ -1027,8 +1024,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'S',
tone: 'teal',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-32.png',
avatarUrl: PEOPLE_AVATAR_URLS.sundarPichai,
},
employees: { type: 'number', value: '734' },
opportunities: {
@@ -1068,51 +1064,120 @@ export const HERO_DATA: HeroHomeDataType = {
],
rows: [
{
id: 'jeff-williams',
id: 'dario-amodei',
cells: {
name: {
type: 'person',
name: 'Jeff Williams',
tone: 'blue',
name: 'Dario Amodei',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.jeffWilliams,
avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
},
company: {
type: 'entity',
name: 'Anthropic',
domain: 'anthropic.com',
},
email: { type: 'link', value: 'jeff@anthropic.com' },
phone: { type: 'text', value: '+33 1 23 45 67 89' },
jobTitle: { type: 'text', value: 'COO' },
city: { type: 'text', value: 'Paris' },
linkedin: { type: 'link', value: 'jeff-williams' },
email: { type: 'link', value: 'dario@anthropic.com' },
phone: { type: 'text', value: '+1 415 555 0101' },
jobTitle: { type: 'text', value: 'CEO' },
city: { type: 'text', value: 'San Francisco' },
linkedin: { type: 'link', value: 'dario-amodei' },
added: { type: 'text', value: 'Jul 3, 2023' },
},
},
{
id: 'craig-federighi',
id: 'ryan-roslansky',
cells: {
name: {
type: 'person',
name: 'Craig Federighi',
tone: 'purple',
name: 'Ryan Roslansky',
tone: 'teal',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.craigFederighi,
avatarUrl: PEOPLE_AVATAR_URLS.ryanRoslansky,
},
company: {
type: 'entity',
name: 'Linkedin',
domain: 'linkedin.com',
},
email: { type: 'link', value: 'craig@linkedin.com' },
phone: { type: 'text', value: '+1 234 567 8900' },
jobTitle: { type: 'text', value: 'SVP Software Engineering' },
city: { type: 'text', value: 'Tabithaville' },
linkedin: { type: 'link', value: 'craig-federighi' },
email: { type: 'link', value: 'ryan@linkedin.com' },
phone: { type: 'text', value: '+1 650 555 0134' },
jobTitle: { type: 'text', value: 'CEO' },
city: { type: 'text', value: 'Sunnyvale' },
linkedin: { type: 'link', 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', value: 'stewart@slack.com' },
phone: { type: 'text', value: '+1 415 555 0142' },
jobTitle: { type: 'text', value: 'Co-founder' },
city: { type: 'text', value: 'San Francisco' },
linkedin: { type: 'link', 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', value: 'ivan@notion.com' },
phone: { type: 'text', value: '+1 628 555 0186' },
jobTitle: { type: 'text', value: 'CEO' },
city: { type: 'text', value: 'San Francisco' },
linkedin: { type: 'link', 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', value: 'dylan@figma.com' },
phone: { type: 'text', value: '+1 415 555 0128' },
jobTitle: { type: 'text', value: 'CEO' },
city: { type: 'text', value: 'San Francisco' },
linkedin: { type: 'link', value: 'dylanfield' },
added: { type: 'text', value: 'Jul 12, 2023' },
},
},
],
}),
},
@@ -1148,10 +1213,10 @@ export const HERO_DATA: HeroHomeDataType = {
},
assignee: {
type: 'person',
name: 'Tim Cook',
tone: 'teal',
name: 'Dario Amodei',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.timCook,
avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
},
dueDate: { type: 'text', value: 'Oct 25, 2023' },
relatedTo: {
@@ -1173,10 +1238,10 @@ export const HERO_DATA: HeroHomeDataType = {
},
assignee: {
type: 'person',
name: 'Eddy Cue',
tone: 'gray',
name: 'Stewart Butterfield',
tone: 'teal',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.eddyCue,
avatarUrl: PEOPLE_AVATAR_URLS.stewartButterfield,
},
dueDate: { type: 'text', value: 'Oct 28, 2023' },
relatedTo: {
@@ -1215,10 +1280,10 @@ export const HERO_DATA: HeroHomeDataType = {
},
createdBy: {
type: 'person',
name: 'Phil Schiller',
tone: 'amber',
name: 'Ivan Zhao',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.philSchiller,
avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
},
relatedTo: {
type: 'entity',
@@ -1239,10 +1304,10 @@ export const HERO_DATA: HeroHomeDataType = {
},
createdBy: {
type: 'person',
name: 'Tim Cook',
tone: 'teal',
name: 'Dylan Field',
tone: 'purple',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.timCook,
avatarUrl: PEOPLE_AVATAR_URLS.dylanField,
},
relatedTo: {
type: 'entity',
@@ -1280,10 +1345,10 @@ export const HERO_DATA: HeroHomeDataType = {
},
createdBy: {
type: 'person',
name: 'Phil Schiller',
tone: 'amber',
name: 'Dario Amodei',
tone: 'gray',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.philSchiller,
avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
},
added: { type: 'text', value: 'Oct 24, 2023' },
},
@@ -1299,10 +1364,10 @@ export const HERO_DATA: HeroHomeDataType = {
},
createdBy: {
type: 'person',
name: 'Jeff Williams',
name: 'Patrick Collison',
tone: 'blue',
kind: 'person',
avatarUrl: PEOPLE_AVATAR_URLS.jeffWilliams,
avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison,
},
added: { type: 'text', value: 'Oct 19, 2023' },
},
@@ -1477,8 +1542,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'I',
tone: 'gray',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-09.png',
avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
},
},
},
@@ -1502,8 +1566,7 @@ export const HERO_DATA: HeroHomeDataType = {
shortLabel: 'I',
tone: 'gray',
kind: 'person',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-09.png',
avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
},
},
},
@@ -18,6 +18,8 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
body: {
text: "Twenty makes it simple. It's clean, intuitive, and built to feel like Notion.",
},
backgroundImageSrc:
'/images/home/three-cards-feature/familiar-interface-gradient.webp',
icon: 'users-group',
illustration: 'familiar-interface',
},
@@ -26,6 +28,8 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
body: {
text: 'Everything updates in real time, with AI chat always ready to help you move faster.',
},
backgroundImageSrc:
'/images/home/three-cards-feature/live-data-gradient.webp',
icon: 'live-data',
illustration: 'live-data',
},
@@ -34,6 +38,8 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
body: {
text: 'Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.',
},
backgroundImageSrc:
'/images/home/three-cards-feature/fast-path-gradient.webp',
icon: 'fast-path',
illustration: 'fast-path',
},
@@ -265,8 +265,9 @@ const IMAGE_HOVER_FADE_IN = 18;
const IMAGE_HOVER_FADE_OUT = 7;
const MAX_PREVIEW_PIXEL_RATIO = 2;
const CanvasMount = styled.div<{ $background: string }>`
background: ${(props) => props.$background};
const CanvasMount = styled.div<{ $background: string; $transparent: boolean }>`
background: ${(props) =>
props.$transparent ? 'transparent' : props.$background};
display: block;
height: 100%;
min-width: 0;
@@ -1618,10 +1619,10 @@ export function HalftoneCanvas({
-interaction.pointerVelocityY * logicalHeight,
);
halftoneMaterial.uniforms.dragOffset.value.set(0, 0);
halftoneMaterial.uniforms.hoverHalftoneActive.value =
activeSettings.animation.hoverHalftoneEnabled
? interaction.hoverStrength
: 0;
halftoneMaterial.uniforms.hoverHalftoneActive.value = activeSettings
.animation.hoverHalftoneEnabled
? interaction.hoverStrength
: 0;
halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
activeSettings.animation.hoverHalftoneEnabled
? activeSettings.animation.hoverHalftonePowerShift
@@ -1632,11 +1633,11 @@ export function HalftoneCanvas({
activeSettings.animation.hoverHalftoneEnabled
? activeSettings.animation.hoverHalftoneWidthShift
: 0;
halftoneMaterial.uniforms.hoverLightStrength.value =
activeSettings.animation.hoverLightEnabled
? activeSettings.animation.hoverLightIntensity *
interaction.hoverStrength
: 0;
halftoneMaterial.uniforms.hoverLightStrength.value = activeSettings
.animation.hoverLightEnabled
? activeSettings.animation.hoverLightIntensity *
interaction.hoverStrength
: 0;
halftoneMaterial.uniforms.hoverLightRadius.value =
activeSettings.animation.hoverLightRadius;
halftoneMaterial.uniforms.hoverFlowStrength.value = 0;
@@ -2031,6 +2032,7 @@ export function HalftoneCanvas({
return (
<CanvasMount
$background={settings.background.color}
$transparent={settings.background.transparent}
aria-hidden
ref={mountReference}
/>
@@ -225,12 +225,12 @@ export const SALESFORCE_DATA: SalesforceDataType = {
productIconAlt: 'Retro help document icon',
productIconSrc: '/images/pricing/salesforce/help-icon.webp',
priceSuffix: ' / seat / month - billed yearly',
productTitle: 'Salesforce Pro',
productTitle: 'Salesfarce Pro',
secondaryCtaNote: 'More options available!',
secondaryCtaHref:
'https://www.salesforce.com/en-us/wp-content/uploads/sites/4/documents/pricing/all-add-ons.pdf',
secondaryCtaLabel: 'Check more add-ons',
totalPriceLabel: 'total per month with fixed cost',
windowTitle: 'Salesforce Pro pricing',
windowTitle: 'Salesfarce Add-on Center',
},
};
@@ -1,5 +1,17 @@
'use client';
import { HalftoneCanvas } from '@/app/halftone/_components/HalftoneCanvas';
import {
DEFAULT_GLASS_ANIMATION_SETTINGS,
DEFAULT_GLASS_BACKGROUND_SETTINGS,
DEFAULT_GLASS_MATERIAL_SETTINGS,
DEFAULT_SHAPE_HALFTONE_SETTINGS,
DEFAULT_SOLID_ANIMATION_SETTINGS,
DEFAULT_SOLID_BACKGROUND_SETTINGS,
DEFAULT_SOLID_MATERIAL_SETTINGS,
type HalftoneMaterialSurface,
type HalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
import { styled } from '@linaria/react';
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
@@ -17,6 +29,7 @@ const MIN_FOOTPRINT_SCALE = 0.001;
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
type HelpedHalftoneRenderer = 'legacy' | 'studio';
type ViewRect = {
x: number;
@@ -47,13 +60,20 @@ export type HelpedHalftoneSettings = {
material: {
roughness: number;
metalness: number;
surface?: HalftoneMaterialSurface;
color?: string;
thickness?: number;
refraction?: number;
environmentPower?: number;
};
halftone: {
enabled: boolean;
scale: number;
power: number;
width: number;
imageContrast?: number;
dashColor: string;
hoverDashColor?: string;
};
animation: {
autoRotateEnabled: boolean;
@@ -91,6 +111,10 @@ export type HelpedHalftoneSettings = {
springDamping: number;
springReturnEnabled: boolean;
springStrength: number;
hoverHalftoneEnabled?: boolean;
hoverHalftonePowerShift?: number;
hoverHalftoneRadius?: number;
hoverHalftoneWidthShift?: number;
hoverLightIntensity: number;
hoverLightRadius: number;
dragFlowDecay: number;
@@ -533,10 +557,7 @@ function getFootprintScaleFromRects(
return 1;
}
return Math.max(
Math.sqrt(currentArea / referenceArea),
MIN_FOOTPRINT_SCALE,
);
return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE);
}
function projectBox3ToViewport({
@@ -552,11 +573,7 @@ function projectBox3ToViewport({
viewportHeight: number;
viewportWidth: number;
}) {
if (
localBounds.isEmpty() ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
if (localBounds.isEmpty() || viewportWidth <= 0 || viewportHeight <= 0) {
return null;
}
@@ -728,6 +745,57 @@ const StyledVisualMount = styled.div`
width: 100%;
`;
const NOOP = () => {};
function getStudioMaterialSurface(
settings: HelpedHalftoneSettings,
): HalftoneMaterialSurface {
return settings.material.surface === 'glass' ? 'glass' : 'solid';
}
function createStudioSettings(
settings: HelpedHalftoneSettings,
): HalftoneStudioSettings {
const surface = getStudioMaterialSurface(settings);
const defaultMaterial =
surface === 'glass'
? DEFAULT_GLASS_MATERIAL_SETTINGS
: DEFAULT_SOLID_MATERIAL_SETTINGS;
const defaultAnimation =
surface === 'glass'
? DEFAULT_GLASS_ANIMATION_SETTINGS
: DEFAULT_SOLID_ANIMATION_SETTINGS;
const defaultBackground =
surface === 'glass'
? DEFAULT_GLASS_BACKGROUND_SETTINGS
: DEFAULT_SOLID_BACKGROUND_SETTINGS;
return {
sourceMode: 'shape',
shapeKey: 'helped',
lighting: { ...settings.lighting },
material: {
...defaultMaterial,
...settings.material,
surface,
},
halftone: {
...DEFAULT_SHAPE_HALFTONE_SETTINGS,
...settings.halftone,
hoverDashColor:
settings.halftone.hoverDashColor ?? settings.halftone.dashColor,
imageContrast:
settings.halftone.imageContrast ??
DEFAULT_SHAPE_HALFTONE_SETTINGS.imageContrast,
},
background: defaultBackground,
animation: {
...defaultAnimation,
...settings.animation,
},
};
}
type HelpedHalftoneCanvasProps = {
geometry: THREE.BufferGeometry;
initialPose: HelpedHalftonePose;
@@ -770,7 +838,9 @@ function HelpedHalftoneCanvas({
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
const canvas = renderer.domElement;
canvas.style.cursor = settings.animation.followDragEnabled ? 'grab' : 'default';
canvas.style.cursor = settings.animation.followDragEnabled
? 'grab'
: 'default';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
@@ -855,7 +925,9 @@ function HelpedHalftoneCanvas({
dashColor: { value: new THREE.Color(settings.halftone.dashColor) },
time: { value: 0 },
waveAmount: {
value: settings.animation.waveEnabled ? settings.animation.waveAmount : 0,
value: settings.animation.waveEnabled
? settings.animation.waveAmount
: 0,
},
waveSpeed: { value: settings.animation.waveSpeed },
footprintScale: { value: 1.0 },
@@ -1073,8 +1145,7 @@ function HelpedHalftoneCanvas({
const floatPhase = elapsedTime * settings.animation.floatSpeed;
const driftAmount = (settings.animation.driftAmount * Math.PI) / 180;
meshOffsetY +=
Math.sin(floatPhase) * settings.animation.floatAmplitude;
meshOffsetY += Math.sin(floatPhase) * settings.animation.floatAmplitude;
baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45;
baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3;
}
@@ -1089,13 +1160,15 @@ function HelpedHalftoneCanvas({
if (rotateEnabled) {
interaction.rotateElapsed += delta;
const rotateProgress = settings.animation.rotatePingPong
? Math.sin(interaction.rotateElapsed * settings.animation.rotateSpeed) *
Math.PI
? Math.sin(
interaction.rotateElapsed * settings.animation.rotateSpeed,
) * Math.PI
: interaction.rotateElapsed * settings.animation.rotateSpeed;
if (settings.animation.rotatePreset === 'axis') {
const axisDirection =
settings.animation.rotateAxis.startsWith('-') ? -1 : 1;
const axisDirection = settings.animation.rotateAxis.startsWith('-')
? -1
: 1;
const axisProgress = rotateProgress * axisDirection;
if (
@@ -1256,8 +1329,7 @@ function HelpedHalftoneCanvas({
const orbitPitch = centeredY * cameraRange * 0.7;
const horizontalRadius = Math.cos(orbitPitch) * baseCameraDistance;
const targetCameraX = Math.sin(orbitYaw) * horizontalRadius;
const targetCameraY =
Math.sin(orbitPitch) * baseCameraDistance * 0.85;
const targetCameraY = Math.sin(orbitPitch) * baseCameraDistance * 0.85;
const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius;
camera.position.x += (targetCameraX - camera.position.x) * cameraEase;
@@ -1266,8 +1338,7 @@ function HelpedHalftoneCanvas({
} else {
camera.position.x += (0 - camera.position.x) * 0.12;
camera.position.y += (0 - camera.position.y) * 0.12;
camera.position.z +=
(baseCameraDistance - camera.position.z) * 0.12;
camera.position.z += (baseCameraDistance - camera.position.z) * 0.12;
}
lookAtTarget.set(0, meshOffsetY * 0.2, 0);
@@ -1329,11 +1400,37 @@ function HelpedHalftoneCanvas({
return <StyledVisualMount aria-hidden ref={mountReference} />;
}
function StudioHelpedHalftoneCanvas({
geometry,
initialPose,
previewDistance,
settings,
}: HelpedHalftoneCanvasProps) {
useEffect(() => {
return () => {
geometry.dispose();
};
}, [geometry]);
return (
<HalftoneCanvas
geometry={geometry}
imageElement={null}
initialPose={initialPose}
onFirstInteraction={NOOP}
onPoseChange={NOOP}
previewDistance={previewDistance}
settings={createStudioSettings(settings)}
/>
);
}
type HelpedHalftoneModelProps = {
initialPose: HelpedHalftonePose;
label: string;
modelUrl: string;
previewDistance: number;
renderer?: HelpedHalftoneRenderer;
settings: HelpedHalftoneSettings;
};
@@ -1342,6 +1439,7 @@ export function HelpedHalftoneModel({
label,
modelUrl,
previewDistance,
renderer = 'legacy',
settings,
}: HelpedHalftoneModelProps) {
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
@@ -1371,6 +1469,17 @@ export function HelpedHalftoneModel({
return <StyledVisualMount aria-hidden />;
}
if (renderer === 'studio') {
return (
<StudioHelpedHalftoneCanvas
geometry={geometry}
initialPose={initialPose}
previewDistance={previewDistance}
settings={settings}
/>
);
}
return (
<HelpedHalftoneCanvas
geometry={geometry}
@@ -11,26 +11,33 @@ const MONEY_PREVIEW_DISTANCE = 5;
const MONEY_SETTINGS: HelpedHalftoneSettings = {
lighting: {
intensity: 1,
intensity: 3,
fillIntensity: 0,
ambientIntensity: 0,
angleDegrees: 103,
height: -3.6,
angleDegrees: 47,
height: 1.4,
},
material: {
roughness: 0.32,
metalness: 0.98,
surface: 'glass',
color: '#7d7d7d',
roughness: 0.26,
metalness: 0.15,
thickness: 15.58,
refraction: 3,
environmentPower: 5,
},
halftone: {
enabled: true,
scale: 14.97,
power: -0.25,
width: 1.4,
dashColor: '#FDFE98',
scale: 25.64,
power: -1.24,
width: 0.5,
imageContrast: 1,
dashColor: '#FEFFB7',
hoverDashColor: '#4A38F5',
},
animation: {
autoRotateEnabled: false,
breatheEnabled: false,
breatheEnabled: true,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: true,
@@ -39,31 +46,35 @@ const MONEY_SETTINGS: HelpedHalftoneSettings = {
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.1,
autoWobble: 0,
breatheAmount: 0.04,
breatheSpeed: 0.8,
autoSpeed: 0.15,
autoWobble: 0.3,
breatheAmount: 0.015,
breatheSpeed: 0.25,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 24,
hoverEase: 0.16,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: '-xy',
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 0.1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepHeightRange: 0,
lightSweepRange: 28,
lightSweepSpeed: 0.2,
springDamping: 0.4,
lightSweepSpeed: 0.25,
springDamping: 0.5,
springReturnEnabled: true,
springStrength: 0.06,
springStrength: 0.1,
hoverHalftoneEnabled: false,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.2,
hoverHalftoneWidthShift: -0.18,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
@@ -79,14 +90,14 @@ const MONEY_SETTINGS: HelpedHalftoneSettings = {
};
const MONEY_INITIAL_POSE: HelpedHalftonePose = {
autoElapsed: 0.05,
autoElapsed: 9.604400000000004,
rotateElapsed: 0,
rotationX: 2.0543643190124016e-89,
rotationY: 1.3695919155712744e-87,
rotationZ: 0,
rotationX: 9.425994949837918e-229,
rotationY: 2.798289108321203e-229,
rotationZ: -2.301463116952276e-231,
targetRotationX: 0,
targetRotationY: 0,
timeElapsed: 482.5497999999506,
timeElapsed: 303.7692999998331,
};
export function Money() {
@@ -96,6 +107,7 @@ export function Money() {
label="money.glb"
modelUrl={GLB_URL}
previewDistance={MONEY_PREVIEW_DISTANCE}
renderer="studio"
settings={MONEY_SETTINGS}
/>
);
@@ -11,26 +11,33 @@ const SPACESHIP_PREVIEW_DISTANCE = 3.5;
const SPACESHIP_SETTINGS: HelpedHalftoneSettings = {
lighting: {
intensity: 1,
intensity: 3,
fillIntensity: 0,
ambientIntensity: 0,
angleDegrees: 103,
height: -3.6,
angleDegrees: 47,
height: 1.4,
},
material: {
roughness: 0.32,
metalness: 0.98,
surface: 'glass',
color: '#7d7d7d',
roughness: 0.26,
metalness: 0.15,
thickness: 15.58,
refraction: 3,
environmentPower: 5,
},
halftone: {
enabled: true,
scale: 14.97,
power: -0.25,
width: 1.4,
scale: 16.64,
power: -1.24,
width: 0.5,
imageContrast: 1,
dashColor: '#89FC9A',
hoverDashColor: '#89FC9A',
},
animation: {
autoRotateEnabled: false,
breatheEnabled: false,
breatheEnabled: true,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: true,
@@ -39,31 +46,35 @@ const SPACESHIP_SETTINGS: HelpedHalftoneSettings = {
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.1,
autoWobble: 0,
breatheAmount: 0.04,
breatheSpeed: 0.8,
autoSpeed: 0.15,
autoWobble: 0.3,
breatheAmount: 0.015,
breatheSpeed: 0.25,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 24,
hoverEase: 0.16,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: '-xy',
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 0.1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepHeightRange: 0,
lightSweepRange: 28,
lightSweepSpeed: 0.2,
springDamping: 0.4,
lightSweepSpeed: 0.25,
springDamping: 0.5,
springReturnEnabled: true,
springStrength: 0.06,
springStrength: 0.1,
hoverHalftoneEnabled: false,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.2,
hoverHalftoneWidthShift: -0.18,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
@@ -96,6 +107,7 @@ export function Spaceship() {
label="spaceship.glb"
modelUrl={GLB_URL}
previewDistance={SPACESHIP_PREVIEW_DISTANCE}
renderer="studio"
settings={SPACESHIP_SETTINGS}
/>
);
@@ -11,26 +11,33 @@ const TARGET_PREVIEW_DISTANCE = 4;
const TARGET_SETTINGS: HelpedHalftoneSettings = {
lighting: {
intensity: 1,
intensity: 3,
fillIntensity: 0,
ambientIntensity: 0,
angleDegrees: 103,
height: -3.6,
angleDegrees: 47,
height: 1.4,
},
material: {
roughness: 0.32,
metalness: 0.98,
surface: 'glass',
color: '#7d7d7d',
roughness: 0.26,
metalness: 0.15,
thickness: 15.58,
refraction: 3,
environmentPower: 5,
},
halftone: {
enabled: true,
scale: 14.97,
power: -0.25,
width: 1.4,
scale: 25.64,
power: -1.24,
width: 0.5,
imageContrast: 1,
dashColor: '#ED87FC',
hoverDashColor: '#ED87FC',
},
animation: {
autoRotateEnabled: false,
breatheEnabled: false,
breatheEnabled: true,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: true,
@@ -39,31 +46,35 @@ const TARGET_SETTINGS: HelpedHalftoneSettings = {
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.1,
autoWobble: 0,
breatheAmount: 0.04,
breatheSpeed: 0.8,
autoSpeed: 0.15,
autoWobble: 0.3,
breatheAmount: 0.015,
breatheSpeed: 0.25,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 24,
hoverEase: 0.16,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: '-xy',
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 0.1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepHeightRange: 0,
lightSweepRange: 28,
lightSweepSpeed: 0.2,
springDamping: 0.4,
lightSweepSpeed: 0.25,
springDamping: 0.5,
springReturnEnabled: true,
springStrength: 0.06,
springStrength: 0.1,
hoverHalftoneEnabled: false,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.2,
hoverHalftoneWidthShift: -0.18,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
@@ -96,6 +107,7 @@ export function Target() {
label="target.glb"
modelUrl={GLB_URL}
previewDistance={TARGET_PREVIEW_DISTANCE}
renderer="studio"
settings={TARGET_SETTINGS}
/>
);
@@ -1,16 +1,114 @@
export const SHARED_COMPANY_LOGO_URLS = {
a16z: '/images/shared/companies/logos/a16z.png',
accel: '/images/shared/companies/logos/accel.png',
airbnb: '/images/shared/companies/logos/airbnb.png',
airtable: '/images/shared/companies/logos/airtable.png',
anthropic: '/images/shared/companies/logos/anthropic.png',
apple: '/images/shared/companies/logos/apple.png',
apple1977: '/images/shared/companies/logos/apple-1977.png',
calendar: '/images/shared/companies/logos/calendar.png',
claude: '/images/shared/companies/logos/claude.png',
cursor: '/images/shared/companies/logos/cursor.png',
docusign: '/images/shared/companies/logos/docusign.png',
figma: '/images/shared/companies/logos/figma.png',
foundersFund: '/images/shared/companies/logos/founders-fund.png',
github: '/images/shared/companies/logos/github.png',
gmail: '/images/shared/companies/logos/gmail.png',
google: '/images/shared/companies/logos/google.png',
hubspot: '/images/shared/companies/logos/hubspot.png',
kleinerPerkins: '/images/shared/companies/logos/kleiner-perkins.png',
lemlist: '/images/shared/companies/logos/lemlist.png',
linear: '/images/shared/companies/logos/linear.svg',
linkedin: '/images/shared/companies/logos/linkedin.png',
mailchimp: '/images/shared/companies/logos/mailchimp.png',
meet: '/images/shared/companies/logos/meet.png',
metabase: '/images/shared/companies/logos/metabase.png',
microsoft: '/images/shared/companies/logos/microsoft.png',
notion: '/images/shared/companies/logos/notion.png',
okta: '/images/shared/companies/logos/okta.png',
openai: '/images/shared/companies/logos/openai.png',
outlook: '/images/shared/companies/logos/outlook.png',
outreach: '/images/shared/companies/logos/outreach.png',
postgresql: '/images/shared/companies/logos/postgresql.png',
qonto: '/images/shared/companies/logos/qonto.png',
salesforce: '/images/shared/companies/logos/salesforce.png',
segment: '/images/shared/companies/logos/segment.png',
sequoia: '/images/shared/companies/logos/sequoia.png',
slack: '/images/shared/companies/logos/slack.png',
stripe: '/images/shared/companies/logos/stripe.png',
tally: '/images/shared/companies/logos/tally.png',
twenty: '/images/shared/companies/logos/twenty.png',
whatsapp: '/images/shared/companies/logos/whatsapp.png',
zapier: '/images/shared/companies/logos/zapier.png',
} as const;
export const SHARED_PEOPLE_AVATAR_URLS = {
alexandreProt: '/images/shared/people/avatars/alexandre-prot.jpg',
anonymousAnna: '/images/shared/people/avatars/anonymous-anna.jpg',
anonymousFabrice: '/images/shared/people/avatars/anonymous-fabrice.jpg',
anonymousFelix: '/images/shared/people/avatars/anonymous-felix.jpg',
anonymousIndira: '/images/shared/people/avatars/anonymous-indira.jpg',
anonymousLaura: '/images/shared/people/avatars/anonymous-laura.jpg',
anonymousMike: '/images/shared/people/avatars/anonymous-mike.jpg',
anonymousThomas: '/images/shared/people/avatars/anonymous-thomas.jpg',
benChestnut: '/images/shared/people/avatars/ben-chestnut.jpg',
brianChesky: '/images/shared/people/avatars/brian-chesky.jpg',
chrisWanstrath: '/images/shared/people/avatars/chris-wanstrath.jpg',
craigFederighi: '/images/shared/people/avatars/craig-federighi.jpg',
darioAmodei: '/images/shared/people/avatars/dario-amodei.jpg',
dylanField: '/images/shared/people/avatars/dylan-field.jpg',
eddyCue: '/images/shared/people/avatars/eddy-cue.jpg',
ivanZhao: '/images/shared/people/avatars/ivan-zhao.jpg',
jeffWilliams: '/images/shared/people/avatars/jeff-williams.jpg',
joeGebbia: '/images/shared/people/avatars/joe-gebbia.jpg',
katherineAdams: '/images/shared/people/avatars/katherine-adams.jpg',
patrickCollison: '/images/shared/people/avatars/patrick-collison.jpg',
pingLi: '/images/shared/people/avatars/ping-li.jpg',
peterReinhardt: '/images/shared/people/avatars/peter-reinhardt.jpg',
peterThiel: '/images/shared/people/avatars/peter-thiel.jpg',
philSchiller: '/images/shared/people/avatars/phil-schiller.jpg',
rayDamm: '/images/shared/people/avatars/ray-damm.jpg',
reidHoffman: '/images/shared/people/avatars/reid-hoffman.jpg',
roelofBotha: '/images/shared/people/avatars/roelof-botha.jpg',
ryanRoslansky: '/images/shared/people/avatars/ryan-roslansky.jpg',
steveAnavi: '/images/shared/people/avatars/steve-anavi.jpg',
stewartButterfield:
'/images/shared/people/avatars/stewart-butterfield.jpg',
sundarPichai: '/images/shared/people/avatars/sundar-pichai.jpg',
thomasDohmke: '/images/shared/people/avatars/thomas-dohmke.jpg',
timCook: '/images/shared/people/avatars/tim-cook.jpg',
} as const;
export const SHARED_COMPANY_LOGO_URLS_BY_DOMAIN = {
'accel.com': SHARED_COMPANY_LOGO_URLS.accel,
'airbnb.com': SHARED_COMPANY_LOGO_URLS.airbnb,
'anthropic.com': SHARED_COMPANY_LOGO_URLS.anthropic,
'apple.com': SHARED_COMPANY_LOGO_URLS.apple,
'figma.com': SHARED_COMPANY_LOGO_URLS.figma,
'foundersfund.com': SHARED_COMPANY_LOGO_URLS.foundersFund,
'github.com': SHARED_COMPANY_LOGO_URLS.github,
'google.com': SHARED_COMPANY_LOGO_URLS.google,
'linkedin.com': SHARED_COMPANY_LOGO_URLS.linkedin,
'mailchimp.com': SHARED_COMPANY_LOGO_URLS.mailchimp,
'notion.com': SHARED_COMPANY_LOGO_URLS.notion,
'qonto.com': SHARED_COMPANY_LOGO_URLS.qonto,
'segment.com': SHARED_COMPANY_LOGO_URLS.segment,
'sequoia.com': SHARED_COMPANY_LOGO_URLS.sequoia,
'slack.com': SHARED_COMPANY_LOGO_URLS.slack,
'stripe.com': SHARED_COMPANY_LOGO_URLS.stripe,
} as const;
export function getSharedCompanyLogoUrlFromDomainName(domainName?: string) {
if (!domainName) {
return undefined;
}
const sanitizedDomain = domainName
.replace(/(https?:\/\/)|(www\.)/g, '')
.replace(/\/.*$/, '')
.toLowerCase();
return SHARED_COMPANY_LOGO_URLS_BY_DOMAIN[
sanitizedDomain as keyof typeof SHARED_COMPANY_LOGO_URLS_BY_DOMAIN
];
}
@@ -1,6 +1,7 @@
'use client';
import dynamic from 'next/dynamic';
import { getSharedCompanyLogoUrlFromDomainName } from '@/lib/shared-asset-paths';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
@@ -1197,6 +1198,12 @@ function sanitizeURL(link: string | null | undefined) {
}
function getLogoUrlFromDomainName(domainName?: string): string | undefined {
const sharedLogoUrl = getSharedCompanyLogoUrlFromDomainName(domainName);
if (sharedLogoUrl) {
return sharedLogoUrl;
}
const sanitizedDomain = sanitizeURL(domainName);
return sanitizedDomain
@@ -1,5 +1,6 @@
'use client';
import { getSharedCompanyLogoUrlFromDomainName } from '@/lib/shared-asset-paths';
import { RatingStarIcon } from '@/icons';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
@@ -321,6 +322,12 @@ function sanitizeURL(link: string | null | undefined) {
}
function getLogoUrlFromDomainName(domainName?: string): string | undefined {
const sharedLogoUrl = getSharedCompanyLogoUrlFromDomainName(domainName);
if (sharedLogoUrl) {
return sharedLogoUrl;
}
const sanitizedDomain = sanitizeURL(domainName);
return sanitizedDomain
@@ -1,5 +1,6 @@
'use client';
import { getSharedCompanyLogoUrlFromDomainName } from '@/lib/shared-asset-paths';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
@@ -448,6 +449,12 @@ function sanitizeURL(link: string | null | undefined) {
}
function getLogoUrlFromDomainName(domainName?: string): string | undefined {
const sharedLogoUrl = getSharedCompanyLogoUrlFromDomainName(domainName);
if (sharedLogoUrl) {
return sharedLogoUrl;
}
const sanitizedDomain = sanitizeURL(domainName);
return sanitizedDomain
@@ -15,25 +15,24 @@ const HOVER_FADE_OUT = 7;
const HALFTONE_SETTINGS = {
animation: {
hoverHalftoneEnabled: true,
hoverHalftonePowerShift: 0.62,
hoverHalftoneRadius: 0.6,
hoverHalftoneEnabled: false,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.45,
hoverHalftoneWidthShift: -0.18,
hoverLightEnabled: false,
hoverLightIntensity: 0.12,
hoverLightRadius: 0.8,
hoverLightEnabled: true,
hoverLightIntensity: 0.35,
hoverLightRadius: 0.42,
waveAmount: 2,
waveEnabled: false,
waveSpeed: 1,
},
halftone: {
dashColor: '#dddddd',
hoverDashColor: '#FFF',
imageContrast: 1.12,
minimumTone: 0.26,
power: 0.18,
scale: 12,
width: 0.72,
dashColor: '#868686',
hoverDashColor: '#F5F5F5',
imageContrast: 1,
power: 0.5,
scale: 8,
width: 0.3,
},
};
@@ -63,7 +62,6 @@ const imagePassthroughFragmentShader = /* glsl */ `
vec2 uv = vUv;
// Cover: match the underlying NextImage background crop.
if (imageAspect > viewAspect) {
float scale = viewAspect / imageAspect;
uv.x = (uv.x - 0.5) * scale + 0.5;
@@ -95,7 +93,6 @@ const halftoneFragmentShader = /* glsl */ `
uniform float s_4;
uniform vec3 dashColor;
uniform vec3 hoverDashColor;
uniform float minimumTone;
uniform float time;
uniform float waveAmount;
uniform float waveSpeed;
@@ -209,17 +206,15 @@ const halftoneFragmentShader = /* glsl */ `
);
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
float tonalAverage = (
float bandRadius = clamp(
(
sceneSample.r +
sceneSample.g +
sceneSample.b +
localPower * length(vec2(0.5))
) *
(1.0 / 3.0)
) + lightLift;
float bandRadius = clamp(
max(tonalAverage, minimumTone),
(1.0 / 3.0) +
lightLift,
0.0,
1.0
) * 1.86 * 0.5;
@@ -391,7 +386,6 @@ async function mountHalftoneCanvas({
logicalResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
minimumTone: { value: HALFTONE_SETTINGS.halftone.minimumTone },
s_3: { value: HALFTONE_SETTINGS.halftone.power },
s_4: { value: HALFTONE_SETTINGS.halftone.width },
tScene: { value: sceneTarget.texture },
@@ -6,7 +6,8 @@ import { StepperVisualFrame } from '../StepperVisualFrame/StepperVisualFrame';
import { StepperBackgroundHalftone } from './StepperBackgroundHalftone';
import { StepperLottie } from './StepperLottie';
const HOME_STEPPER_BACKGROUND = '/images/home/stepper/gears.jpg';
const HOME_STEPPER_BACKGROUND =
'/images/home/stepper/download-worker.webp';
const HOME_STEPPER_SHAPE = '/images/home/stepper/background-shape.webp';
type VisualProps = {
@@ -16,7 +17,7 @@ type VisualProps = {
export function Visual({ scrollProgress }: VisualProps) {
return (
<StepperVisualFrame
backgroundColor="#F5F5F5"
backgroundColor="#424242"
backgroundSrc={HOME_STEPPER_BACKGROUND}
backgroundOverlay={
<VisibleWhenTabActive>
@@ -86,7 +86,7 @@ export function Root(props: RootProps) {
<SignoffShape fillColor={shapeFillColor} />
) : null}
{page === Pages.Partner ? (
<GuideCrosshair crossX="calc(50% - 334px)" crossY="198px" />
<GuideCrosshair crossX="calc(50% + 334px)" crossY="198px" />
) : null}
<StyledContainer>{children}</StyledContainer>
</StyledSection>
@@ -0,0 +1,921 @@
'use client';
import { styled } from '@linaria/react';
import { type RefObject, useEffect, useRef } from 'react';
import * as THREE from 'three';
const DEFAULT_IMAGE_URL =
'/images/home/three-cards-feature/familiar-interface-gradient.webp';
const VIRTUAL_RENDER_HEIGHT = 800;
const REFERENCE_PREVIEW_DISTANCE = 4;
const PREVIEW_DISTANCE = REFERENCE_PREVIEW_DISTANCE;
const MIN_FOOTPRINT_SCALE = 0.001;
const HALFTONE_SCALE = 18;
const HALFTONE_POWER = -0.3;
const HALFTONE_WIDTH = 0.3;
const IMAGE_CONTRAST = 0.7;
const DASH_COLOR = '#777';
const HOVER_DASH_COLOR = '#777';
const HOVER_LIGHT_INTENSITY = 0.85;
const HOVER_LIGHT_RADIUS = 0.6;
const HOVER_HALFTONE_RADIUS = 0.45;
const IMAGE_POINTER_FOLLOW = 0.38;
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
const IMAGE_HOVER_FADE_IN = 18;
const IMAGE_HOVER_FADE_OUT = 7;
export type HalftoneBackdropConfig = {
activeHoverX?: number;
activeHoverY?: number;
dashColor?: string;
halftonePower?: number;
halftoneScale?: number;
halftoneWidth?: number;
hoverDashColor?: string;
hoverHalftoneRadius?: number;
hoverLightIntensity?: number;
hoverLightRadius?: number;
imageContrast?: number;
previewDistance?: number;
};
type ResolvedHalftoneBackdropConfig = Required<HalftoneBackdropConfig>;
const DEFAULT_HALFTONE_BACKDROP_CONFIG: ResolvedHalftoneBackdropConfig = {
activeHoverX: 0.16,
activeHoverY: 0.46,
dashColor: DASH_COLOR,
halftonePower: HALFTONE_POWER,
halftoneScale: HALFTONE_SCALE,
halftoneWidth: HALFTONE_WIDTH,
hoverDashColor: HOVER_DASH_COLOR,
hoverHalftoneRadius: HOVER_HALFTONE_RADIUS,
hoverLightIntensity: HOVER_LIGHT_INTENSITY,
hoverLightRadius: HOVER_LIGHT_RADIUS,
imageContrast: IMAGE_CONTRAST,
previewDistance: PREVIEW_DISTANCE,
};
const passThroughVertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
const blurFragmentShader = `
precision highp float;
uniform sampler2D tInput;
uniform vec2 dir;
uniform vec2 res;
varying vec2 vUv;
void main() {
vec4 sum = vec4(0.0);
vec2 px = dir / res;
float w[5];
w[0] = 0.227027;
w[1] = 0.1945946;
w[2] = 0.1216216;
w[3] = 0.054054;
w[4] = 0.016216;
sum += texture2D(tInput, vUv) * w[0];
for (int i = 1; i < 5; i++) {
float fi = float(i) * 3.0;
sum += texture2D(tInput, vUv + px * fi) * w[i];
sum += texture2D(tInput, vUv - px * fi) * w[i];
}
gl_FragColor = sum;
}
`;
const halftoneFragmentShader = `
precision highp float;
uniform sampler2D tScene;
uniform vec2 effectResolution;
uniform vec2 logicalResolution;
uniform float tile;
uniform float s_3;
uniform float s_4;
uniform vec3 dashColor;
uniform vec3 hoverDashColor;
uniform float footprintScale;
uniform vec2 interactionUv;
uniform vec2 interactionVelocity;
uniform float hoverLightStrength;
uniform float hoverLightRadius;
varying vec2 vUv;
float distSegment(in vec2 p, in vec2 a, in vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float denom = max(dot(ba, ba), 0.000001);
float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
return length(pa - ba * h);
}
float lineSimpleEt(in vec2 p, in float r, in float thickness) {
vec2 a = vec2(0.5) + vec2(-r, 0.0);
vec2 b = vec2(0.5) + vec2(r, 0.0);
float distToSegment = distSegment(p, a, b);
float halfThickness = thickness * r;
return distToSegment - halfThickness;
}
void main() {
vec2 fragCoord =
(gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);
vec2 pointerPx = interactionUv * logicalResolution;
vec2 fragDelta = fragCoord - pointerPx;
float fragDist = length(fragDelta);
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
float velocityMagnitude = length(interactionVelocity);
vec2 motionDir = velocityMagnitude > 0.001
? interactionVelocity / velocityMagnitude
: vec2(0.0, 0.0);
float motionBias = velocityMagnitude > 0.001
? dot(-radialDir, motionDir) * 0.5 + 0.5
: 0.5;
float hoverLightMask = 0.0;
if (hoverLightStrength > 0.0) {
float lightRadiusPx = hoverLightRadius * logicalResolution.y;
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
}
vec2 effectCoord = fragCoord;
vec2 cellIndex = floor(effectCoord / halftoneSize);
vec2 sampleUv = clamp(
(cellIndex + 0.5) * halftoneSize / logicalResolution,
vec2(0.0),
vec2(1.0)
);
vec2 cellUv = fract(effectCoord / halftoneSize);
vec4 sceneSample = texture2D(tScene, sampleUv);
float mask = smoothstep(0.02, 0.08, sceneSample.a);
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
float bandRadius = clamp(
(
(
sceneSample.r +
sceneSample.g +
sceneSample.b +
s_3 * length(vec2(0.5))
) *
(1.0 / 3.0)
) + lightLift,
0.0,
1.0
) * 1.86 * 0.5;
float alpha = 0.0;
if (bandRadius > 0.0001) {
float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4);
float edge = 0.02;
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
}
float hoverStrength = clamp(hoverLightMask * hoverLightStrength, 0.0, 1.0);
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverStrength);
vec3 color = activeDashColor * alpha;
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
const imagePassthroughFragmentShader = `
precision highp float;
uniform sampler2D tImage;
uniform vec2 imageSize;
uniform vec2 viewportSize;
uniform float zoom;
uniform float contrast;
varying vec2 vUv;
void main() {
float imageAspect = imageSize.x / imageSize.y;
float viewAspect = viewportSize.x / viewportSize.y;
vec2 uv = vUv;
if (imageAspect > viewAspect) {
float scale = viewAspect / imageAspect;
uv.x = (uv.x - 0.5) * scale + 0.5;
} else {
float scale = imageAspect / viewAspect;
uv.y = (uv.y - 0.5) * scale + 0.5;
}
uv = (uv - 0.5) / zoom + 0.5;
uv.y = 1.0 - uv.y;
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
* step(0.0, uv.y) * step(uv.y, 1.0);
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
gl_FragColor = vec4(contrastColor, inBounds);
}
`;
type Rect = {
x: number;
y: number;
width: number;
height: number;
};
type InteractionState = {
hoverStrength: number;
mouseX: number;
mouseY: number;
pointerInside: boolean;
pointerVelocityX: number;
pointerVelocityY: number;
smoothedMouseX: number;
smoothedMouseY: number;
};
function clampRectToViewport(
rect: Rect,
viewportWidth: number,
viewportHeight: number,
) {
const minX = Math.max(rect.x, 0);
const minY = Math.max(rect.y, 0);
const maxX = Math.min(rect.x + rect.width, viewportWidth);
const maxY = Math.min(rect.y + rect.height, viewportHeight);
if (maxX <= minX || maxY <= minY) {
return null;
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
function getRectArea(rect: Rect | null) {
if (!rect) {
return 0;
}
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
}
function getImagePreviewZoom(previewDistance: number) {
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
}
function getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom,
}: {
imageHeight: number;
imageWidth: number;
viewportHeight: number;
viewportWidth: number;
zoom: number;
}) {
if (
imageWidth <= 0 ||
imageHeight <= 0 ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return null;
}
const imageAspect = imageWidth / imageHeight;
const viewAspect = viewportWidth / viewportHeight;
let fittedWidth = viewportWidth;
let fittedHeight = viewportHeight;
if (imageAspect > viewAspect) {
fittedHeight = viewportWidth / imageAspect;
} else {
fittedWidth = viewportHeight * imageAspect;
}
const scaledWidth = fittedWidth * zoom;
const scaledHeight = fittedHeight * zoom;
return clampRectToViewport(
{
x: (viewportWidth - scaledWidth) * 0.5,
y: (viewportHeight - scaledHeight) * 0.5,
width: scaledWidth,
height: scaledHeight,
},
viewportWidth,
viewportHeight,
);
}
function getFootprintScaleFromRects(
currentRect: Rect | null,
referenceRect: Rect | null,
) {
const currentArea = getRectArea(currentRect);
const referenceArea = getRectArea(referenceRect);
if (currentArea <= 0 || referenceArea <= 0) {
return 1;
}
return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE);
}
function getImageFootprintScale({
imageHeight,
imageWidth,
previewDistance,
viewportHeight,
viewportWidth,
}: {
imageHeight: number;
imageWidth: number;
previewDistance: number;
viewportHeight: number;
viewportWidth: number;
}) {
const currentRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: getImagePreviewZoom(previewDistance),
});
const referenceRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: 1,
});
return getFootprintScaleFromRects(currentRect, referenceRect);
}
function createRenderTarget(width: number, height: number) {
return new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
});
}
function createInteractionState(): InteractionState {
return {
hoverStrength: 0,
mouseX: 0.5,
mouseY: 0.5,
pointerInside: false,
pointerVelocityX: 0,
pointerVelocityY: 0,
smoothedMouseX: 0.5,
smoothedMouseY: 0.5,
};
}
function loadImage(imageUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
image.src = imageUrl;
});
}
async function mountFamiliarInterfaceGradient({
container,
config,
imageUrl,
isExternallyActive,
pointerTarget,
}: {
container: HTMLDivElement;
config: ResolvedHalftoneBackdropConfig;
imageUrl: string;
isExternallyActive: () => boolean;
pointerTarget?: HTMLElement | null;
}) {
const HOVER_STRENGTH_STOP_EPSILON = 0.001;
const POINTER_POSITION_STOP_EPSILON = 0.001;
const POINTER_VELOCITY_STOP_EPSILON = 0.0001;
const image = await loadImage(imageUrl);
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
const getVirtualWidth = () =>
Math.max(
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
1,
);
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
const canvas = renderer.domElement;
canvas.style.cursor = 'default';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
container.appendChild(canvas);
const trackingElement = pointerTarget ?? canvas;
const imageTexture = new THREE.Texture(image);
imageTexture.colorSpace = THREE.SRGBColorSpace;
imageTexture.needsUpdate = true;
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const imageMaterial = new THREE.ShaderMaterial({
uniforms: {
contrast: { value: config.imageContrast },
imageSize: { value: new THREE.Vector2(image.width, image.height) },
tImage: { value: imageTexture },
viewportSize: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
zoom: { value: getImagePreviewZoom(config.previewDistance) },
},
fragmentShader: imagePassthroughFragmentShader,
vertexShader: passThroughVertexShader,
});
const blurHorizontalMaterial = new THREE.ShaderMaterial({
uniforms: {
dir: { value: new THREE.Vector2(1, 0) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
tInput: { value: null },
},
fragmentShader: blurFragmentShader,
vertexShader: passThroughVertexShader,
});
const blurVerticalMaterial = new THREE.ShaderMaterial({
uniforms: {
dir: { value: new THREE.Vector2(0, 1) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
tInput: { value: null },
},
fragmentShader: blurFragmentShader,
vertexShader: passThroughVertexShader,
});
const halftoneMaterial = new THREE.ShaderMaterial({
fragmentShader: halftoneFragmentShader,
transparent: true,
uniforms: {
dashColor: { value: new THREE.Color(config.dashColor) },
effectResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
footprintScale: { value: 1 },
hoverDashColor: { value: new THREE.Color(config.hoverDashColor) },
hoverLightRadius: { value: config.hoverLightRadius },
hoverLightStrength: { value: 0 },
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
interactionVelocity: { value: new THREE.Vector2(0, 0) },
logicalResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
s_3: { value: config.halftonePower },
s_4: { value: config.halftoneWidth },
tScene: { value: sceneTarget.texture },
tile: { value: config.halftoneScale },
},
vertexShader: passThroughVertexShader,
});
const fullScreenMesh = new THREE.Mesh(fullScreenGeometry, imageMaterial);
const blurHorizontalMesh = new THREE.Mesh(
fullScreenGeometry,
blurHorizontalMaterial,
);
const blurVerticalMesh = new THREE.Mesh(
fullScreenGeometry,
blurVerticalMaterial,
);
const postProcessMesh = new THREE.Mesh(fullScreenGeometry, halftoneMaterial);
const imageScene = new THREE.Scene();
imageScene.add(fullScreenMesh);
const blurHorizontalScene = new THREE.Scene();
blurHorizontalScene.add(blurHorizontalMesh);
const blurVerticalScene = new THREE.Scene();
blurVerticalScene.add(blurVerticalMesh);
const postScene = new THREE.Scene();
postScene.add(postProcessMesh);
const updateViewportUniforms = (
logicalWidth: number,
logicalHeight: number,
effectWidth: number,
effectHeight: number,
) => {
blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
halftoneMaterial.uniforms.effectResolution.value.set(
effectWidth,
effectHeight,
);
halftoneMaterial.uniforms.logicalResolution.value.set(
logicalWidth,
logicalHeight,
);
imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
};
const getHalftoneScale = () =>
getImageFootprintScale({
imageHeight: image.height,
imageWidth: image.width,
previewDistance: config.previewDistance,
viewportHeight: getVirtualHeight(),
viewportWidth: getVirtualWidth(),
});
const interaction = createInteractionState();
let animationFrameId: number | null = null;
let lastFrameTime: number | null = null;
const renderBackdrop = (deltaSeconds: number) => {
const externalActive = isExternallyActive();
const isHoverActive = externalActive || interaction.pointerInside;
const targetHoverStrength = isHoverActive ? 1 : 0;
const hoverEasing =
1 -
Math.exp(
-deltaSeconds *
(isHoverActive ? IMAGE_HOVER_FADE_IN : IMAGE_HOVER_FADE_OUT),
);
interaction.hoverStrength +=
(targetHoverStrength - interaction.hoverStrength) * hoverEasing;
if (
Math.abs(targetHoverStrength - interaction.hoverStrength) <=
HOVER_STRENGTH_STOP_EPSILON
) {
interaction.hoverStrength = targetHoverStrength;
}
const targetMouseX =
externalActive && !interaction.pointerInside
? config.activeHoverX
: interaction.mouseX;
const targetMouseY =
externalActive && !interaction.pointerInside
? config.activeHoverY
: interaction.mouseY;
interaction.smoothedMouseX +=
(targetMouseX - interaction.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
interaction.smoothedMouseY +=
(targetMouseY - interaction.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
if (
Math.abs(targetMouseX - interaction.smoothedMouseX) <=
POINTER_POSITION_STOP_EPSILON
) {
interaction.smoothedMouseX = targetMouseX;
}
if (
Math.abs(targetMouseY - interaction.smoothedMouseY) <=
POINTER_POSITION_STOP_EPSILON
) {
interaction.smoothedMouseY = targetMouseY;
}
interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING;
interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING;
if (
Math.abs(interaction.pointerVelocityX) <= POINTER_VELOCITY_STOP_EPSILON
) {
interaction.pointerVelocityX = 0;
}
if (
Math.abs(interaction.pointerVelocityY) <= POINTER_VELOCITY_STOP_EPSILON
) {
interaction.pointerVelocityY = 0;
}
halftoneMaterial.uniforms.interactionUv.value.set(
interaction.smoothedMouseX,
1 - interaction.smoothedMouseY,
);
halftoneMaterial.uniforms.interactionVelocity.value.set(
interaction.pointerVelocityX * getVirtualWidth(),
-interaction.pointerVelocityY * getVirtualHeight(),
);
halftoneMaterial.uniforms.hoverLightStrength.value =
config.hoverLightIntensity * interaction.hoverStrength;
halftoneMaterial.uniforms.hoverLightRadius.value =
config.hoverHalftoneRadius + config.hoverLightRadius * 0.5;
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(
config.previewDistance,
);
renderer.setRenderTarget(sceneTarget);
renderer.render(imageScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(postScene, orthographicCamera);
return (
isHoverActive ||
interaction.hoverStrength !== targetHoverStrength ||
interaction.smoothedMouseX !== targetMouseX ||
interaction.smoothedMouseY !== targetMouseY ||
interaction.pointerVelocityX !== 0 ||
interaction.pointerVelocityY !== 0
);
};
const runFrame = (timestamp: number) => {
animationFrameId = null;
const deltaSeconds =
lastFrameTime === null
? 1 / 60
: Math.min((timestamp - lastFrameTime) / 1000, 0.1);
lastFrameTime = timestamp;
if (renderBackdrop(deltaSeconds)) {
animationFrameId = window.requestAnimationFrame(runFrame);
return;
}
lastFrameTime = null;
};
const ensureAnimationLoop = () => {
if (animationFrameId !== null) {
return;
}
animationFrameId = window.requestAnimationFrame(runFrame);
};
const syncSize = () => {
const virtualWidth = getVirtualWidth();
const virtualHeight = getVirtualHeight();
renderer.setSize(virtualWidth, virtualHeight, false);
sceneTarget.setSize(virtualWidth, virtualHeight);
blurTargetA.setSize(virtualWidth, virtualHeight);
blurTargetB.setSize(virtualWidth, virtualHeight);
updateViewportUniforms(
virtualWidth,
virtualHeight,
virtualWidth,
virtualHeight,
);
if (animationFrameId === null) {
renderBackdrop(0);
}
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
syncSize();
const updatePointerPosition = (
event: PointerEvent,
options?: { resetVelocity?: boolean },
) => {
const rect = trackingElement.getBoundingClientRect();
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
const nextMouseX = THREE.MathUtils.clamp(
(event.clientX - rect.left) / width,
0,
1,
);
const nextMouseY = THREE.MathUtils.clamp(
(event.clientY - rect.top) / height,
0,
1,
);
const deltaX = nextMouseX - interaction.mouseX;
const deltaY = nextMouseY - interaction.mouseY;
interaction.mouseX = nextMouseX;
interaction.mouseY = nextMouseY;
interaction.pointerInside =
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
if (options?.resetVelocity) {
interaction.pointerVelocityX = 0;
interaction.pointerVelocityY = 0;
interaction.smoothedMouseX = nextMouseX;
interaction.smoothedMouseY = nextMouseY;
return;
}
interaction.pointerVelocityX = deltaX;
interaction.pointerVelocityY = deltaY;
};
const handlePointerMove = (event: PointerEvent) => {
const shouldReset = !interaction.pointerInside;
updatePointerPosition(
event,
shouldReset ? { resetVelocity: true } : undefined,
);
ensureAnimationLoop();
};
const handlePointerLeave = () => {
interaction.pointerInside = false;
interaction.pointerVelocityX = 0;
interaction.pointerVelocityY = 0;
ensureAnimationLoop();
};
trackingElement.addEventListener('pointermove', handlePointerMove);
trackingElement.addEventListener('pointerleave', handlePointerLeave);
if (isExternallyActive()) {
ensureAnimationLoop();
}
return {
dispose: () => {
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId);
}
resizeObserver.disconnect();
trackingElement.removeEventListener('pointermove', handlePointerMove);
trackingElement.removeEventListener('pointerleave', handlePointerLeave);
blurHorizontalMaterial.dispose();
blurVerticalMaterial.dispose();
halftoneMaterial.dispose();
imageMaterial.dispose();
imageTexture.dispose();
fullScreenGeometry.dispose();
sceneTarget.dispose();
blurTargetA.dispose();
blurTargetB.dispose();
renderer.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
},
wake: ensureAnimationLoop,
};
}
const VisualMount = styled.div`
background: transparent;
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
type FamiliarInterfaceGradientBackdropProps = {
active?: boolean;
config?: HalftoneBackdropConfig;
imageUrl?: string;
pointerTargetRef?: RefObject<HTMLElement | null>;
};
export function FamiliarInterfaceGradientBackdrop({
active = false,
config,
imageUrl = DEFAULT_IMAGE_URL,
pointerTargetRef,
}: FamiliarInterfaceGradientBackdropProps) {
const mountRef = useRef<HTMLDivElement>(null);
const activeRef = useRef(active);
const wakeRef = useRef<VoidFunction | null>(null);
const resolvedConfig = {
...DEFAULT_HALFTONE_BACKDROP_CONFIG,
...config,
};
activeRef.current = active;
useEffect(() => {
const container = mountRef.current;
if (!container) {
return;
}
let dispose: VoidFunction | undefined;
let cancelled = false;
void mountFamiliarInterfaceGradient({
config: resolvedConfig,
container,
imageUrl,
isExternallyActive: () => activeRef.current,
pointerTarget: pointerTargetRef?.current,
})
.then((mountedGradient) => {
if (cancelled) {
mountedGradient.dispose();
return;
}
wakeRef.current = mountedGradient.wake;
dispose = mountedGradient.dispose;
})
.catch((error) => {
console.error(error);
});
return () => {
cancelled = true;
wakeRef.current = null;
dispose?.();
};
}, [
imageUrl,
resolvedConfig.dashColor,
resolvedConfig.halftonePower,
resolvedConfig.halftoneScale,
resolvedConfig.halftoneWidth,
resolvedConfig.hoverDashColor,
resolvedConfig.hoverHalftoneRadius,
resolvedConfig.hoverLightIntensity,
resolvedConfig.hoverLightRadius,
resolvedConfig.imageContrast,
resolvedConfig.previewDistance,
]);
useEffect(() => {
wakeRef.current?.();
}, [active]);
return <VisualMount aria-hidden ref={mountRef} />;
}
@@ -9,10 +9,13 @@ import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
type PointerEvent as ReactPointerEvent,
type RefObject,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { FamiliarInterfaceGradientBackdrop } from './FamiliarInterfaceGradientBackdrop';
import {
IconBuildingSkyscraper,
IconCalendarEvent,
@@ -40,6 +43,8 @@ const FIGMA_FIELD_STACK_GAP = 1.839;
const FIGMA_CARD_RADIUS = 3.677;
const FIGMA_CARD_SHADOW =
'0px 0px 3.677px rgba(0, 0, 0, 0.08), 0px 1.839px 3.677px rgba(0, 0, 0, 0.04)';
const FIGMA_COLUMN_GAP = 8;
const FIGMA_COLUMN_SIDE_PADDING = 5.516;
const FIGMA_HEADER_PADDING_X = 5.516;
const FIGMA_HEADER_PADDING_TOP = 7.354;
const FIGMA_HEADER_PADDING_BOTTOM = 3.677;
@@ -66,15 +71,20 @@ type CardPlacement =
| { laneIndex: LaneIndex; type: 'lane' };
type DropTarget = { cardIndex: number; laneIndex: LaneIndex };
type LaneCards = [CardId[], CardId[]];
type FamiliarInterfaceVisualProps = { active?: boolean };
type FamiliarInterfaceVisualProps = {
active?: boolean;
backgroundImageRotationDeg?: number;
backgroundImageSrc?: string;
pointerTargetRef?: RefObject<HTMLElement | null>;
};
const COLORS = {
backdrop: '#1b1b1b',
backdropStripe: 'rgba(255, 255, 255, 0.28)',
border: '#ebebeb',
borderLight: '#f1f1f1',
borderStrong: '#d6d6d6',
boardSurface: '#ffffff',
cardSurface: '#fcfcfc',
activeCardBorder: '#b5ccff',
activeCardSurface: '#e8f1ff',
imageAreaSurface: '#f5f5f3',
@@ -288,7 +298,7 @@ const SceneViewport = styled.div`
height: ${SCENE_HEIGHT}px;
left: 50%;
position: absolute;
top: 9px;
top: 0;
transform: translateX(-50%) scale(${SCENE_SCALE});
transform-origin: top center;
width: ${SCENE_WIDTH}px;
@@ -309,35 +319,20 @@ const SceneFrame = styled.div`
overflow: hidden;
position: relative;
width: 411px;
`;
&::before {
background:
radial-gradient(
circle at 1px 1px,
rgba(255, 255, 255, 0.09) 1px,
transparent 0
)
right top / 12px 12px repeat,
linear-gradient(rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.02));
content: '';
inset: 0;
opacity: 0.42;
pointer-events: none;
position: absolute;
}
&::after {
background: repeating-linear-gradient(
180deg,
transparent 0 11px,
${COLORS.backdropStripe} 11px 15px,
transparent 15px 27px
);
content: '';
inset: 0;
pointer-events: none;
position: absolute;
}
const SceneBackdrop = styled.div<{
$backgroundImageRotationDeg?: number;
}>`
background-color: ${COLORS.backdrop};
inset: 0;
overflow: hidden;
pointer-events: none;
position: absolute;
transform: rotate(
${({ $backgroundImageRotationDeg = 0 }) => $backgroundImageRotationDeg}deg
);
transform-origin: center center;
`;
const BoardGroup = styled.div<{ $active: boolean }>`
@@ -434,10 +429,13 @@ const BoardTitleCount = styled.span`
`;
const ColumnsHeaderGrid = styled.div`
background: ${COLORS.boardSurface};
display: grid;
grid-template-columns: repeat(2, 186.355px);
justify-content: center;
min-height: 29.354px;
padding-left: 7.354px;
position: relative;
z-index: 1;
`;
const LaneHeader = styled.div`
@@ -446,7 +444,7 @@ const LaneHeader = styled.div`
display: flex;
gap: 4px;
min-height: 29.354px;
padding: 7.354px 7.354px 0;
padding: 7.354px ${FIGMA_COLUMN_SIDE_PADDING}px 0;
&:last-child {
border-right: none;
@@ -480,18 +478,18 @@ const LaneCount = styled.span`
const ColumnsGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, 186.355px);
justify-content: center;
flex: 1 1 auto;
min-height: 0;
padding-left: 7.354px;
`;
const LaneBody = styled.div`
border-right: 1px solid ${COLORS.borderLight};
display: flex;
flex-direction: column;
gap: 7.354px;
gap: ${FIGMA_COLUMN_GAP}px;
min-height: 0;
padding: 7.354px 7.354px 8px;
padding: 7.354px ${FIGMA_COLUMN_SIDE_PADDING}px 8px;
&:last-child {
border-right: none;
@@ -511,7 +509,7 @@ const AddCardRow = styled.div`
const OpportunityCard = styled.div<{ $variant?: 'active' | 'board' }>`
background: ${({ $variant }) =>
$variant === 'active' ? COLORS.activeCardSurface : COLORS.boardSurface};
$variant === 'active' ? COLORS.activeCardSurface : COLORS.cardSurface};
border: ${({ $variant }) => ($variant === 'active' ? '0.919px' : '1px')} solid
${({ $variant }) =>
$variant === 'active' ? COLORS.activeCardBorder : COLORS.border};
@@ -519,7 +517,26 @@ const OpportunityCard = styled.div<{ $variant?: 'active' | 'board' }>`
box-shadow: ${FIGMA_CARD_SHADOW};
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
transition: background-color 120ms ease;
width: ${FIGMA_CARD_WIDTH}px;
&::after {
background: rgba(0, 0, 0, 0.04);
content: '';
inset: 0;
opacity: 0;
pointer-events: none;
position: absolute;
transition: opacity 120ms ease;
}
@media (hover: hover) {
&:hover::after {
opacity: ${({ $variant }) => ($variant === 'board' ? 1 : 0)};
}
}
`;
const CardHeader = styled.div`
@@ -1163,7 +1180,11 @@ function OpportunityPreviewCard({
export function FamiliarInterfaceVisual({
active = false,
backgroundImageRotationDeg,
backgroundImageSrc,
pointerTargetRef,
}: FamiliarInterfaceVisualProps) {
const rootRef = useRef<HTMLDivElement>(null);
const sceneFrameRef = useRef<HTMLDivElement>(null);
const interactionLayerRef = useRef<HTMLDivElement>(null);
const laneBodyRefs = useRef<(HTMLDivElement | null)[]>([]);
@@ -1187,8 +1208,11 @@ export function FamiliarInterfaceVisual({
const [draggedCardId, setDraggedCardId] = useState<CardId | null>(null);
const [hasDraggedCard, setHasDraggedCard] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const activePointerIdRef = useRef<number | null>(null);
const dragStateRef = useRef<{
cardId: CardId;
lastClientX: number;
lastClientY: number;
maxX: number;
maxY: number;
minX: number;
@@ -1272,6 +1296,51 @@ export function FamiliarInterfaceVisual({
pendingCardAnimationRectsRef.current = nextCardRects;
};
const releaseActivePointerCapture = () => {
const activePointerId = activePointerIdRef.current;
const interactionLayer = interactionLayerRef.current;
if (
activePointerId !== null &&
interactionLayer?.hasPointerCapture(activePointerId)
) {
interactionLayer.releasePointerCapture(activePointerId);
}
activePointerIdRef.current = null;
};
const clearDragState = () => {
dragStateRef.current = null;
setActiveCardId(null);
setDraggedCardId(null);
setIsDragging(false);
};
const updateDragOffsetFromPointer = (clientX: number, clientY: number) => {
const currentDragState = dragStateRef.current;
if (currentDragState === null) {
return;
}
currentDragState.lastClientX = clientX;
currentDragState.lastClientY = clientY;
const nextX = clamp(
currentDragState.originX + clientX - currentDragState.pointerX,
currentDragState.minX,
currentDragState.maxX,
);
const nextY = clamp(
currentDragState.originY + clientY - currentDragState.pointerY,
currentDragState.minY,
currentDragState.maxY,
);
setDragOffset({ x: nextX, y: nextY });
};
const getDropTarget = (
clientX: number,
clientY: number,
@@ -1332,6 +1401,10 @@ export function FamiliarInterfaceVisual({
return;
}
if (dragStateRef.current !== null) {
return;
}
setHasDraggedCard(true);
setActiveCardId(cardId);
setDraggedCardId(cardId);
@@ -1347,6 +1420,8 @@ export function FamiliarInterfaceVisual({
dragStateRef.current = {
cardId,
lastClientX: event.clientX,
lastClientY: event.clientY,
maxX: interactionLayerRect.width - cardRect.width,
maxY: interactionLayerRect.height - cardRect.height,
minX: 0,
@@ -1359,37 +1434,11 @@ export function FamiliarInterfaceVisual({
};
setDragOffset({ x: originX, y: originY });
setIsDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
activePointerIdRef.current = event.pointerId;
interactionLayerRef.current?.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (dragStateRef.current === null) {
return;
}
event.preventDefault();
const nextX = clamp(
dragStateRef.current.originX +
event.clientX -
dragStateRef.current.pointerX,
dragStateRef.current.minX,
dragStateRef.current.maxX,
);
const nextY = clamp(
dragStateRef.current.originY +
event.clientY -
dragStateRef.current.pointerY,
dragStateRef.current.minY,
dragStateRef.current.maxY,
);
setDragOffset({ x: nextX, y: nextY });
};
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const finishDragAtPosition = (clientX: number, clientY: number) => {
const currentDragState = dragStateRef.current;
if (currentDragState === null) {
@@ -1398,22 +1447,18 @@ export function FamiliarInterfaceVisual({
const releasedOffset = {
x: clamp(
currentDragState.originX + event.clientX - currentDragState.pointerX,
currentDragState.originX + clientX - currentDragState.pointerX,
currentDragState.minX,
currentDragState.maxX,
),
y: clamp(
currentDragState.originY + event.clientY - currentDragState.pointerY,
currentDragState.originY + clientY - currentDragState.pointerY,
currentDragState.minY,
currentDragState.maxY,
),
};
const dropTarget = getDropTarget(
event.clientX,
event.clientY,
currentDragState.cardId,
);
const dropTarget = getDropTarget(clientX, clientY, currentDragState.cardId);
if (dropTarget !== null) {
captureCardAnimationRects(currentDragState.cardId);
@@ -1442,14 +1487,72 @@ export function FamiliarInterfaceVisual({
setFloatingPosition(releasedOffset);
}
dragStateRef.current = null;
setDraggedCardId(null);
setIsDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
clearDragState();
};
const handleCapturedPointerMove = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.pointerId !== activePointerIdRef.current) {
return;
}
event.preventDefault();
updateDragOffsetFromPointer(event.clientX, event.clientY);
};
const handleCapturedPointerUp = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.pointerId !== activePointerIdRef.current) {
return;
}
event.preventDefault();
finishDragAtPosition(event.clientX, event.clientY);
releaseActivePointerCapture();
};
const handleCapturedPointerCancel = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.pointerId !== activePointerIdRef.current) {
return;
}
event.preventDefault();
const currentDragState = dragStateRef.current;
if (currentDragState !== null) {
finishDragAtPosition(
currentDragState.lastClientX,
currentDragState.lastClientY,
);
}
releaseActivePointerCapture();
};
const handleLostPointerCapture = () => {
const currentDragState = dragStateRef.current;
if (currentDragState !== null) {
finishDragAtPosition(
currentDragState.lastClientX,
currentDragState.lastClientY,
);
}
activePointerIdRef.current = null;
};
useEffect(() => {
return () => {
releaseActivePointerCapture();
};
}, []);
const showHandCursor =
!hasDraggedCard && !isDragging && activeCardId === null;
@@ -1468,9 +1571,6 @@ export function FamiliarInterfaceVisual({
onPointerDown={(event) => {
handlePointerDown(event, cardId, { laneIndex, type: 'lane' });
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={isDraggedCard ? { visibility: 'hidden' } : undefined}
>
<OpportunityPreviewCard
@@ -1482,9 +1582,18 @@ export function FamiliarInterfaceVisual({
};
return (
<VisualRoot aria-hidden="true">
<VisualRoot aria-hidden="true" ref={rootRef}>
<SceneViewport>
<SceneFrame ref={sceneFrameRef}>
<SceneBackdrop
$backgroundImageRotationDeg={backgroundImageRotationDeg}
>
<FamiliarInterfaceGradientBackdrop
active={active}
imageUrl={backgroundImageSrc}
pointerTargetRef={pointerTargetRef ?? rootRef}
/>
</SceneBackdrop>
<BoardGroup $active={active}>
<BoardSurface>
<BoardTitleRow>
@@ -1559,7 +1668,13 @@ export function FamiliarInterfaceVisual({
</ColumnsGrid>
</BoardSurface>
<InteractionLayer ref={interactionLayerRef}>
<InteractionLayer
ref={interactionLayerRef}
onLostPointerCapture={handleLostPointerCapture}
onPointerCancel={handleCapturedPointerCancel}
onPointerMove={handleCapturedPointerMove}
onPointerUp={handleCapturedPointerUp}
>
{showHandCursor ? (
<DragCursor $active={active}>
<DragCursorInner $active={active}>
@@ -1578,9 +1693,6 @@ export function FamiliarInterfaceVisual({
type: 'floating',
});
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{
transform: `translate3d(${floatingPosition.x}px, ${floatingPosition.y}px, 0)`,
visibility:
@@ -0,0 +1,924 @@
'use client';
import { styled } from '@linaria/react';
import { type RefObject, useEffect, useRef } from 'react';
import * as THREE from 'three';
const DEFAULT_IMAGE_URL =
'/images/home/three-cards-feature/fast-path-gradient.webp';
const VIRTUAL_RENDER_HEIGHT = 1200;
const REFERENCE_PREVIEW_DISTANCE = 4;
const PREVIEW_DISTANCE = REFERENCE_PREVIEW_DISTANCE;
const MIN_FOOTPRINT_SCALE = 0.001;
const HALFTONE_SCALE = 28;
const HALFTONE_POWER = 0.3;
const HALFTONE_WIDTH = 0.3;
const IMAGE_CONTRAST = 1;
const DASH_COLOR = '#777';
const HOVER_DASH_COLOR = '#777';
const HOVER_LIGHT_INTENSITY = 0.85;
const HOVER_LIGHT_RADIUS = 0.6;
const HOVER_HALFTONE_RADIUS = 0.45;
const IMAGE_POINTER_FOLLOW = 0.38;
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
const IMAGE_HOVER_FADE_IN = 18;
const IMAGE_HOVER_FADE_OUT = 7;
export type HalftoneBackdropConfig = {
activeHoverX?: number;
activeHoverY?: number;
dashColor?: string;
halftonePower?: number;
halftoneScale?: number;
halftoneWidth?: number;
hoverDashColor?: string;
hoverHalftoneRadius?: number;
hoverLightIntensity?: number;
hoverLightRadius?: number;
imageContrast?: number;
previewDistance?: number;
};
type ResolvedHalftoneBackdropConfig = Required<HalftoneBackdropConfig>;
const DEFAULT_HALFTONE_BACKDROP_CONFIG: ResolvedHalftoneBackdropConfig = {
activeHoverX: 0.16,
activeHoverY: 0.46,
dashColor: DASH_COLOR,
halftonePower: HALFTONE_POWER,
halftoneScale: HALFTONE_SCALE,
halftoneWidth: HALFTONE_WIDTH,
hoverDashColor: HOVER_DASH_COLOR,
hoverHalftoneRadius: HOVER_HALFTONE_RADIUS,
hoverLightIntensity: HOVER_LIGHT_INTENSITY,
hoverLightRadius: HOVER_LIGHT_RADIUS,
imageContrast: IMAGE_CONTRAST,
previewDistance: PREVIEW_DISTANCE,
};
const passThroughVertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
const blurFragmentShader = `
precision highp float;
uniform sampler2D tInput;
uniform vec2 dir;
uniform vec2 res;
varying vec2 vUv;
void main() {
vec4 sum = vec4(0.0);
vec2 px = dir / res;
float w[5];
w[0] = 0.227027;
w[1] = 0.1945946;
w[2] = 0.1216216;
w[3] = 0.054054;
w[4] = 0.016216;
sum += texture2D(tInput, vUv) * w[0];
for (int i = 1; i < 5; i++) {
float fi = float(i) * 3.0;
sum += texture2D(tInput, vUv + px * fi) * w[i];
sum += texture2D(tInput, vUv - px * fi) * w[i];
}
gl_FragColor = sum;
}
`;
const halftoneFragmentShader = `
precision highp float;
uniform sampler2D tScene;
uniform vec2 effectResolution;
uniform vec2 logicalResolution;
uniform float tile;
uniform float s_3;
uniform float s_4;
uniform vec3 dashColor;
uniform vec3 hoverDashColor;
uniform float footprintScale;
uniform vec2 interactionUv;
uniform vec2 interactionVelocity;
uniform float hoverLightStrength;
uniform float hoverLightRadius;
varying vec2 vUv;
float distSegment(in vec2 p, in vec2 a, in vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float denom = max(dot(ba, ba), 0.000001);
float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
return length(pa - ba * h);
}
float lineSimpleEt(in vec2 p, in float r, in float thickness) {
vec2 a = vec2(0.5) + vec2(-r, 0.0);
vec2 b = vec2(0.5) + vec2(r, 0.0);
float distToSegment = distSegment(p, a, b);
float halfThickness = thickness * r;
return distToSegment - halfThickness;
}
void main() {
vec2 fragCoord =
(gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);
vec2 pointerPx = interactionUv * logicalResolution;
vec2 fragDelta = fragCoord - pointerPx;
float fragDist = length(fragDelta);
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
float velocityMagnitude = length(interactionVelocity);
vec2 motionDir = velocityMagnitude > 0.001
? interactionVelocity / velocityMagnitude
: vec2(0.0, 0.0);
float motionBias = velocityMagnitude > 0.001
? dot(-radialDir, motionDir) * 0.5 + 0.5
: 0.5;
float hoverLightMask = 0.0;
if (hoverLightStrength > 0.0) {
float lightRadiusPx = hoverLightRadius * logicalResolution.y;
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
}
vec2 effectCoord = fragCoord;
vec2 cellIndex = floor(effectCoord / halftoneSize);
vec2 sampleUv = clamp(
(cellIndex + 0.5) * halftoneSize / logicalResolution,
vec2(0.0),
vec2(1.0)
);
vec2 cellUv = fract(effectCoord / halftoneSize);
vec4 sceneSample = texture2D(tScene, sampleUv);
float mask = smoothstep(0.02, 0.08, sceneSample.a);
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
float bandRadius = clamp(
(
(
sceneSample.r +
sceneSample.g +
sceneSample.b +
s_3 * length(vec2(0.5))
) *
(1.0 / 3.0)
) + lightLift,
0.0,
1.0
) * 1.86 * 0.5;
float alpha = 0.0;
if (bandRadius > 0.0001) {
float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4);
float edge = 0.02;
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
}
float hoverStrength = clamp(hoverLightMask * hoverLightStrength, 0.0, 1.0);
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverStrength);
vec3 color = activeDashColor * alpha;
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
const imagePassthroughFragmentShader = `
precision highp float;
uniform sampler2D tImage;
uniform vec2 imageSize;
uniform vec2 viewportSize;
uniform float zoom;
uniform float contrast;
varying vec2 vUv;
void main() {
float imageAspect = imageSize.x / imageSize.y;
float viewAspect = viewportSize.x / viewportSize.y;
vec2 uv = vUv;
if (imageAspect > viewAspect) {
float scale = viewAspect / imageAspect;
uv.x = (uv.x - 0.5) * scale + 0.5;
} else {
float scale = imageAspect / viewAspect;
uv.y = (uv.y - 0.5) * scale + 0.5;
}
uv = (uv - 0.5) / zoom + 0.5;
uv.y = 1.0 - uv.y;
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
* step(0.0, uv.y) * step(uv.y, 1.0);
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
gl_FragColor = vec4(contrastColor, inBounds);
}
`;
type Rect = {
x: number;
y: number;
width: number;
height: number;
};
type InteractionState = {
hoverStrength: number;
mouseX: number;
mouseY: number;
pointerInside: boolean;
pointerVelocityX: number;
pointerVelocityY: number;
smoothedMouseX: number;
smoothedMouseY: number;
};
function clampRectToViewport(
rect: Rect,
viewportWidth: number,
viewportHeight: number,
) {
const minX = Math.max(rect.x, 0);
const minY = Math.max(rect.y, 0);
const maxX = Math.min(rect.x + rect.width, viewportWidth);
const maxY = Math.min(rect.y + rect.height, viewportHeight);
if (maxX <= minX || maxY <= minY) {
return null;
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
function getRectArea(rect: Rect | null) {
if (!rect) {
return 0;
}
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
}
function getImagePreviewZoom(previewDistance: number) {
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
}
function getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom,
}: {
imageHeight: number;
imageWidth: number;
viewportHeight: number;
viewportWidth: number;
zoom: number;
}) {
if (
imageWidth <= 0 ||
imageHeight <= 0 ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return null;
}
const imageAspect = imageWidth / imageHeight;
const viewAspect = viewportWidth / viewportHeight;
let fittedWidth = viewportWidth;
let fittedHeight = viewportHeight;
if (imageAspect > viewAspect) {
fittedHeight = viewportWidth / imageAspect;
} else {
fittedWidth = viewportHeight * imageAspect;
}
const scaledWidth = fittedWidth * zoom;
const scaledHeight = fittedHeight * zoom;
return clampRectToViewport(
{
x: (viewportWidth - scaledWidth) * 0.5,
y: (viewportHeight - scaledHeight) * 0.5,
width: scaledWidth,
height: scaledHeight,
},
viewportWidth,
viewportHeight,
);
}
function getFootprintScaleFromRects(
currentRect: Rect | null,
referenceRect: Rect | null,
) {
const currentArea = getRectArea(currentRect);
const referenceArea = getRectArea(referenceRect);
if (currentArea <= 0 || referenceArea <= 0) {
return 1;
}
return Math.max(
Math.sqrt(currentArea / referenceArea),
MIN_FOOTPRINT_SCALE,
);
}
function getImageFootprintScale({
imageHeight,
imageWidth,
previewDistance,
viewportHeight,
viewportWidth,
}: {
imageHeight: number;
imageWidth: number;
previewDistance: number;
viewportHeight: number;
viewportWidth: number;
}) {
const currentRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: getImagePreviewZoom(previewDistance),
});
const referenceRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: 1,
});
return getFootprintScaleFromRects(currentRect, referenceRect);
}
function createRenderTarget(width: number, height: number) {
return new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
});
}
function createInteractionState(): InteractionState {
return {
hoverStrength: 0,
mouseX: 0.5,
mouseY: 0.5,
pointerInside: false,
pointerVelocityX: 0,
pointerVelocityY: 0,
smoothedMouseX: 0.5,
smoothedMouseY: 0.5,
};
}
function loadImage(imageUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
image.src = imageUrl;
});
}
async function mountFastPathGradient({
container,
config,
imageUrl,
isExternallyActive,
pointerTarget,
}: {
container: HTMLDivElement;
config: ResolvedHalftoneBackdropConfig;
imageUrl: string;
isExternallyActive: () => boolean;
pointerTarget?: HTMLElement | null;
}) {
const HOVER_STRENGTH_STOP_EPSILON = 0.001;
const POINTER_POSITION_STOP_EPSILON = 0.001;
const POINTER_VELOCITY_STOP_EPSILON = 0.0001;
const image = await loadImage(imageUrl);
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
const getVirtualWidth = () =>
Math.max(
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
1,
);
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
const canvas = renderer.domElement;
canvas.style.cursor = 'default';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
container.appendChild(canvas);
const trackingElement = pointerTarget ?? canvas;
const imageTexture = new THREE.Texture(image);
imageTexture.colorSpace = THREE.SRGBColorSpace;
imageTexture.needsUpdate = true;
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const imageMaterial = new THREE.ShaderMaterial({
uniforms: {
contrast: { value: config.imageContrast },
imageSize: { value: new THREE.Vector2(image.width, image.height) },
tImage: { value: imageTexture },
viewportSize: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
zoom: { value: getImagePreviewZoom(config.previewDistance) },
},
fragmentShader: imagePassthroughFragmentShader,
vertexShader: passThroughVertexShader,
});
const blurHorizontalMaterial = new THREE.ShaderMaterial({
uniforms: {
dir: { value: new THREE.Vector2(1, 0) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
tInput: { value: null },
},
fragmentShader: blurFragmentShader,
vertexShader: passThroughVertexShader,
});
const blurVerticalMaterial = new THREE.ShaderMaterial({
uniforms: {
dir: { value: new THREE.Vector2(0, 1) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
tInput: { value: null },
},
fragmentShader: blurFragmentShader,
vertexShader: passThroughVertexShader,
});
const halftoneMaterial = new THREE.ShaderMaterial({
fragmentShader: halftoneFragmentShader,
transparent: true,
uniforms: {
dashColor: { value: new THREE.Color(config.dashColor) },
effectResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
footprintScale: { value: 1 },
hoverDashColor: { value: new THREE.Color(config.hoverDashColor) },
hoverLightRadius: { value: config.hoverLightRadius },
hoverLightStrength: { value: 0 },
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
interactionVelocity: { value: new THREE.Vector2(0, 0) },
logicalResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
s_3: { value: config.halftonePower },
s_4: { value: config.halftoneWidth },
tScene: { value: sceneTarget.texture },
tile: { value: config.halftoneScale },
},
vertexShader: passThroughVertexShader,
});
const fullScreenMesh = new THREE.Mesh(fullScreenGeometry, imageMaterial);
const blurHorizontalMesh = new THREE.Mesh(
fullScreenGeometry,
blurHorizontalMaterial,
);
const blurVerticalMesh = new THREE.Mesh(
fullScreenGeometry,
blurVerticalMaterial,
);
const postProcessMesh = new THREE.Mesh(fullScreenGeometry, halftoneMaterial);
const imageScene = new THREE.Scene();
imageScene.add(fullScreenMesh);
const blurHorizontalScene = new THREE.Scene();
blurHorizontalScene.add(blurHorizontalMesh);
const blurVerticalScene = new THREE.Scene();
blurVerticalScene.add(blurVerticalMesh);
const postScene = new THREE.Scene();
postScene.add(postProcessMesh);
const updateViewportUniforms = (
logicalWidth: number,
logicalHeight: number,
effectWidth: number,
effectHeight: number,
) => {
blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
halftoneMaterial.uniforms.effectResolution.value.set(
effectWidth,
effectHeight,
);
halftoneMaterial.uniforms.logicalResolution.value.set(
logicalWidth,
logicalHeight,
);
imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
};
const getHalftoneScale = () =>
getImageFootprintScale({
imageHeight: image.height,
imageWidth: image.width,
previewDistance: config.previewDistance,
viewportHeight: getVirtualHeight(),
viewportWidth: getVirtualWidth(),
});
const interaction = createInteractionState();
let animationFrameId: number | null = null;
let lastFrameTime: number | null = null;
const renderBackdrop = (deltaSeconds: number) => {
const externalActive = isExternallyActive();
const isHoverActive = externalActive || interaction.pointerInside;
const targetHoverStrength = isHoverActive ? 1 : 0;
const hoverEasing =
1 -
Math.exp(
-deltaSeconds *
(isHoverActive ? IMAGE_HOVER_FADE_IN : IMAGE_HOVER_FADE_OUT),
);
interaction.hoverStrength +=
(targetHoverStrength - interaction.hoverStrength) * hoverEasing;
if (
Math.abs(targetHoverStrength - interaction.hoverStrength) <=
HOVER_STRENGTH_STOP_EPSILON
) {
interaction.hoverStrength = targetHoverStrength;
}
const targetMouseX =
externalActive && !interaction.pointerInside
? config.activeHoverX
: interaction.mouseX;
const targetMouseY =
externalActive && !interaction.pointerInside
? config.activeHoverY
: interaction.mouseY;
interaction.smoothedMouseX +=
(targetMouseX - interaction.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
interaction.smoothedMouseY +=
(targetMouseY - interaction.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
if (
Math.abs(targetMouseX - interaction.smoothedMouseX) <=
POINTER_POSITION_STOP_EPSILON
) {
interaction.smoothedMouseX = targetMouseX;
}
if (
Math.abs(targetMouseY - interaction.smoothedMouseY) <=
POINTER_POSITION_STOP_EPSILON
) {
interaction.smoothedMouseY = targetMouseY;
}
interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING;
interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING;
if (
Math.abs(interaction.pointerVelocityX) <= POINTER_VELOCITY_STOP_EPSILON
) {
interaction.pointerVelocityX = 0;
}
if (
Math.abs(interaction.pointerVelocityY) <= POINTER_VELOCITY_STOP_EPSILON
) {
interaction.pointerVelocityY = 0;
}
halftoneMaterial.uniforms.interactionUv.value.set(
interaction.smoothedMouseX,
1 - interaction.smoothedMouseY,
);
halftoneMaterial.uniforms.interactionVelocity.value.set(
interaction.pointerVelocityX * getVirtualWidth(),
-interaction.pointerVelocityY * getVirtualHeight(),
);
halftoneMaterial.uniforms.hoverLightStrength.value =
config.hoverLightIntensity * interaction.hoverStrength;
halftoneMaterial.uniforms.hoverLightRadius.value =
config.hoverHalftoneRadius + config.hoverLightRadius * 0.5;
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(
config.previewDistance,
);
renderer.setRenderTarget(sceneTarget);
renderer.render(imageScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(postScene, orthographicCamera);
return (
isHoverActive ||
interaction.hoverStrength !== targetHoverStrength ||
interaction.smoothedMouseX !== targetMouseX ||
interaction.smoothedMouseY !== targetMouseY ||
interaction.pointerVelocityX !== 0 ||
interaction.pointerVelocityY !== 0
);
};
const runFrame = (timestamp: number) => {
animationFrameId = null;
const deltaSeconds =
lastFrameTime === null
? 1 / 60
: Math.min((timestamp - lastFrameTime) / 1000, 0.1);
lastFrameTime = timestamp;
if (renderBackdrop(deltaSeconds)) {
animationFrameId = window.requestAnimationFrame(runFrame);
return;
}
lastFrameTime = null;
};
const ensureAnimationLoop = () => {
if (animationFrameId !== null) {
return;
}
animationFrameId = window.requestAnimationFrame(runFrame);
};
const syncSize = () => {
const virtualWidth = getVirtualWidth();
const virtualHeight = getVirtualHeight();
renderer.setSize(virtualWidth, virtualHeight, false);
sceneTarget.setSize(virtualWidth, virtualHeight);
blurTargetA.setSize(virtualWidth, virtualHeight);
blurTargetB.setSize(virtualWidth, virtualHeight);
updateViewportUniforms(
virtualWidth,
virtualHeight,
virtualWidth,
virtualHeight,
);
if (animationFrameId === null) {
renderBackdrop(0);
}
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
syncSize();
const updatePointerPosition = (
event: PointerEvent,
options?: { resetVelocity?: boolean },
) => {
const rect = trackingElement.getBoundingClientRect();
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
const nextMouseX = THREE.MathUtils.clamp(
(event.clientX - rect.left) / width,
0,
1,
);
const nextMouseY = THREE.MathUtils.clamp(
(event.clientY - rect.top) / height,
0,
1,
);
const deltaX = nextMouseX - interaction.mouseX;
const deltaY = nextMouseY - interaction.mouseY;
interaction.mouseX = nextMouseX;
interaction.mouseY = nextMouseY;
interaction.pointerInside =
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
if (options?.resetVelocity) {
interaction.pointerVelocityX = 0;
interaction.pointerVelocityY = 0;
interaction.smoothedMouseX = nextMouseX;
interaction.smoothedMouseY = nextMouseY;
return;
}
interaction.pointerVelocityX = deltaX;
interaction.pointerVelocityY = deltaY;
};
const handlePointerMove = (event: PointerEvent) => {
const shouldReset = !interaction.pointerInside;
updatePointerPosition(
event,
shouldReset ? { resetVelocity: true } : undefined,
);
ensureAnimationLoop();
};
const handlePointerLeave = () => {
interaction.pointerInside = false;
interaction.pointerVelocityX = 0;
interaction.pointerVelocityY = 0;
ensureAnimationLoop();
};
trackingElement.addEventListener('pointermove', handlePointerMove);
trackingElement.addEventListener('pointerleave', handlePointerLeave);
if (isExternallyActive()) {
ensureAnimationLoop();
}
return {
dispose: () => {
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId);
}
resizeObserver.disconnect();
trackingElement.removeEventListener('pointermove', handlePointerMove);
trackingElement.removeEventListener('pointerleave', handlePointerLeave);
blurHorizontalMaterial.dispose();
blurVerticalMaterial.dispose();
halftoneMaterial.dispose();
imageMaterial.dispose();
imageTexture.dispose();
fullScreenGeometry.dispose();
sceneTarget.dispose();
blurTargetA.dispose();
blurTargetB.dispose();
renderer.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
},
wake: ensureAnimationLoop,
};
}
const VisualMount = styled.div`
background: transparent;
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
type FastPathGradientBackdropProps = {
active?: boolean;
config?: HalftoneBackdropConfig;
imageUrl?: string;
pointerTargetRef?: RefObject<HTMLElement | null>;
};
export function FastPathGradientBackdrop({
active = false,
config,
imageUrl = DEFAULT_IMAGE_URL,
pointerTargetRef,
}: FastPathGradientBackdropProps) {
const mountRef = useRef<HTMLDivElement>(null);
const activeRef = useRef(active);
const wakeRef = useRef<VoidFunction | null>(null);
const resolvedConfig = {
...DEFAULT_HALFTONE_BACKDROP_CONFIG,
...config,
};
activeRef.current = active;
useEffect(() => {
const container = mountRef.current;
if (!container) {
return;
}
let dispose: VoidFunction | undefined;
let cancelled = false;
void mountFastPathGradient({
config: resolvedConfig,
container,
imageUrl,
isExternallyActive: () => activeRef.current,
pointerTarget: pointerTargetRef?.current,
})
.then((mountedGradient) => {
if (cancelled) {
mountedGradient.dispose();
return;
}
wakeRef.current = mountedGradient.wake;
dispose = mountedGradient.dispose;
})
.catch((error) => {
console.error(error);
});
return () => {
cancelled = true;
wakeRef.current = null;
dispose?.();
};
}, [
imageUrl,
resolvedConfig.dashColor,
resolvedConfig.halftonePower,
resolvedConfig.halftoneScale,
resolvedConfig.halftoneWidth,
resolvedConfig.hoverDashColor,
resolvedConfig.hoverHalftoneRadius,
resolvedConfig.hoverLightIntensity,
resolvedConfig.hoverLightRadius,
resolvedConfig.imageContrast,
resolvedConfig.previewDistance,
]);
useEffect(() => {
wakeRef.current?.();
}, [active]);
return <VisualMount aria-hidden ref={mountRef} />;
}

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