refactor: harden website runtime, routing, and hero visual (#20113)

This centralizes routing/SEO ownership, replaces deprecated middleware
with proxy, adds safer lifecycle/runtime primitives, and introduces
visual error boundaries so broken WebGL/canvas-heavy visuals can fail
gracefully instead of taking down the page. It also hardens animation,
resize, visibility, cleanup, and WebGL fallback paths for a broader
range of browsers and devices.

The hero visual was split from large monolithic files into focused
domain folders for shell, pages, shared primitives, window interactions,
terminal conversation, prompt, editor, and traffic-light behavior.
Legacy unused section code was removed, visual configs were extracted,
state/geometry logic was moved into testable modules, and coverage was
added across routing, SEO, lifecycle, animation, visual runtime,
halftone behavior, and hero interactions.

More changes on the way, but this should make the website a lot more
stable - disabling WebGL on Firefox and loading the website does not
cause crashes on local any longer, will test on dev once this is merged.
This commit is contained in:
Abdullah.
2026-04-28 20:15:28 +05:00
committed by GitHub
parent 7ea1dfdd49
commit 6aec449a56
261 changed files with 13959 additions and 21070 deletions
@@ -31,6 +31,26 @@ const RULES = [
'preference defaults.',
].join('\n '),
},
{
id: 'no-raw-animation-frame',
description:
'`requestAnimationFrame(...)` / `cancelAnimationFrame(...)` may only be used inside shared runtime primitives.',
pattern: /\b(?:window\.)?(?:requestAnimationFrame|cancelAnimationFrame)\s*\(/,
appliesTo: (rel) =>
rel.startsWith('src/') && /\.(ts|tsx|mjs|js|jsx)$/.test(rel),
exempt: (rel) =>
rel.startsWith('src/lib/animation/') ||
rel.startsWith('src/lib/visual-runtime/') ||
rel.includes('__tests__') ||
rel === 'src/app/[locale]/halftone/_lib/exporters.ts' ||
rel.endsWith('.d.ts'),
help: [
'Use `createAnimationFrameLoop` from `@/lib/animation` for one-shot',
'or UI frame scheduling. Use `createVisualRenderLoop` from',
'`@/lib/visual-runtime` for canvas/WebGL renderers so tab visibility,',
'element visibility, cleanup, and render failures are handled consistently.',
].join('\n '),
},
];
const SKIP_DIRS = new Set([
@@ -11,7 +11,6 @@ import { MENU_DATA } from '@/sections/Menu/data';
import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/lib/pages';
import { ArrowRightUpIcon } from '@/icons';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Faq } from '@/sections/Faq/components';
@@ -22,23 +21,15 @@ import { Menu } from '@/sections/Menu/components';
import { Problem } from '@/sections/Problem/components';
import { Testimonials } from '@/sections/Testimonials/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { buildLocalizedMetadata } from '@/lib/seo';
import { buildRouteMetadata } from '@/lib/seo';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
export const generateMetadata = buildLocalizedMetadata({
path: '/',
title: 'Twenty | #1 open source CRM',
description:
'The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.',
});
export const generateMetadata = buildRouteMetadata('home');
const HOME_TOP_BACKGROUND_COLOR = '#F4F4F4';
const PRODUCT_HUNT_LAUNCH_URL =
'https://www.producthunt.com/products/twenty-crm?launch=twenty-2-0';
const PRODUCT_HUNT_BRAND_COLOR = '#DA552F';
const HeroHeadingGroup = styled.div`
align-items: center;
@@ -52,58 +43,6 @@ const HeroHeadingGroup = styled.div`
}
`;
const HeroLaunchChip = styled.a`
align-items: center;
background: ${theme.colors.primary.background[100]};
border: 1px solid ${theme.colors.primary.border[10]};
border-radius: 999px;
color: ${theme.colors.primary.text[100]};
display: inline-flex;
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(2.5)};
font-weight: ${theme.font.weight.medium};
gap: ${theme.spacing(2)};
line-height: ${theme.lineHeight(3)};
padding: ${theme.spacing(2)} ${theme.spacing(3)};
text-decoration: none;
text-transform: uppercase;
transition:
border-color 180ms ease,
color 180ms ease,
transform 180ms ease;
white-space: nowrap;
&:is(:hover, :focus-visible) {
border-color: ${PRODUCT_HUNT_BRAND_COLOR};
color: ${PRODUCT_HUNT_BRAND_COLOR};
transform: translateY(-1px);
}
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
`;
const HeroLaunchChipDot = styled.span`
background: ${PRODUCT_HUNT_BRAND_COLOR};
border-radius: 999px;
display: block;
flex-shrink: 0;
height: ${theme.spacing(2)};
width: ${theme.spacing(2)};
`;
const HeroLaunchChipLabel = styled.span`
align-items: center;
display: inline-flex;
gap: ${theme.spacing(1.5)};
`;
const HeroIntroGroup = styled.div`
align-items: center;
display: flex;
@@ -177,17 +116,6 @@ export default async function HomePage() {
<Hero.Root backgroundColor={HOME_TOP_BACKGROUND_COLOR} showHomeBackground>
<HeroIntroGroup data-halftone-exclude>
<HeroHeadingGroup>
<HeroLaunchChip
href={PRODUCT_HUNT_LAUNCH_URL}
rel="noopener noreferrer"
target="_blank"
>
<HeroLaunchChipDot />
<HeroLaunchChipLabel>
Live on Product Hunt
<ArrowRightUpIcon size={8} strokeColor="currentColor" />
</HeroLaunchChipLabel>
</HeroLaunchChip>
<Hero.Heading page={Pages.Home} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Home} body={HERO_DATA.body} size="sm" />
</HeroHeadingGroup>
@@ -14,15 +14,10 @@ import { Menu } from '@/sections/Menu/components';
import { Signoff } from '@/sections/Signoff/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import { buildLocalizedMetadata } from '@/lib/seo';
import { buildRouteMetadata } from '@/lib/seo';
import { css } from '@linaria/core';
export const generateMetadata = buildLocalizedMetadata({
path: '/customers',
title: 'Customers | Twenty',
description:
'Meet the teams running their business on Twenty. Real customer stories on how they shaped the CRM to fit their workflow.',
});
export const generateMetadata = buildRouteMetadata('customers');
const HERO_HEADING = [
{ text: 'See how teams ', fontFamily: 'serif' as const },
@@ -5,6 +5,7 @@ import {
buttonBaseStyles,
} from '@/design-system/components/Button/BaseButton';
import { Body, Heading } from '@/design-system/components';
import { useTimeoutRegistry } from '@/lib/react';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
@@ -133,6 +134,7 @@ const nextStepItemClassName = css`
export function EnterpriseActivateClient() {
const searchParams = useSearchParams();
const sessionId = searchParams.get('session_id');
const timeoutRegistry = useTimeoutRegistry();
const [result, setResult] = useState<ActivationResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -146,14 +148,21 @@ export function EnterpriseActivateClient() {
return;
}
const abortController = new AbortController();
const activate = async () => {
try {
const response = await fetch(
`/api/enterprise/activate?session_id=${encodeURIComponent(sessionId)}`,
{ signal: abortController.signal },
);
const data: { error?: string } & Partial<ActivationResult> =
await response.json();
if (abortController.signal.aborted) {
return;
}
if (!response.ok) {
setError(data.error ?? 'Activation failed');
@@ -170,13 +179,23 @@ export function EnterpriseActivateClient() {
setError('Activation response was incomplete.');
}
} catch {
if (abortController.signal.aborted) {
return;
}
setError('Failed to activate enterprise key. Please try again.');
} finally {
setLoading(false);
if (!abortController.signal.aborted) {
setLoading(false);
}
}
};
void activate();
return () => {
abortController.abort();
};
}, [sessionId]);
const handleCopy = async () => {
@@ -186,7 +205,7 @@ export function EnterpriseActivateClient() {
await navigator.clipboard.writeText(result.enterpriseKey);
setCopied(true);
setTimeout(() => {
timeoutRegistry.schedule(() => {
setCopied(false);
}, 2000);
};
@@ -8,16 +8,11 @@ import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'
import { Hero } from '@/sections/Hero/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildLocalizedMetadata } from '@/lib/seo';
import { buildRouteMetadata } from '@/lib/seo';
import { Suspense } from 'react';
import { styled } from '@linaria/react';
export const generateMetadata = buildLocalizedMetadata({
path: '/enterprise/activate',
title: 'Enterprise activation | Twenty',
description:
'Complete activation for your Twenty self-hosted enterprise license.',
});
export const generateMetadata = buildRouteMetadata('enterpriseActivate');
const ENTERPRISE_ACTIVATE_HEADING: HeadingType[] = [
{ text: 'Enterprise ', fontFamily: 'serif' },
@@ -40,6 +40,7 @@ import {
} from '@/lib/halftone/state';
import { Logo as LogoIcon } from '@/icons';
import { LocalizedLink } from '@/lib/i18n';
import { useTimeoutRegistry } from '@/lib/react';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
@@ -57,6 +58,7 @@ const DESKTOP_CONTROLS_PANEL_WIDTH = 320;
const DESKTOP_CONTROLS_PANEL_OFFSET = 20;
const DESKTOP_CONTROLS_PANEL_FOOTPRINT =
DESKTOP_CONTROLS_PANEL_WIDTH + DESKTOP_CONTROLS_PANEL_OFFSET;
const STATUS_CLEAR_DELAY_MS = 2000;
const StudioShell = styled.div<{ $background: string }>`
background: ${(props) => props.$background};
@@ -389,6 +391,14 @@ export function HalftoneStudio() {
shape: { ...DEFAULT_SHAPE_HALFTONE_SETTINGS },
image: { ...DEFAULT_IMAGE_HALFTONE_SETTINGS },
});
const timeoutRegistry = useTimeoutRegistry();
const clearStatusLater = useCallback(() => {
timeoutRegistry.schedule(
() => dispatch({ type: 'clearStatus' }),
STATUS_CLEAR_DELAY_MS,
);
}, [timeoutRegistry]);
const selectedShape = useMemo(
() =>
@@ -525,7 +535,7 @@ export function HalftoneStudio() {
.writeText(url)
.then(() => {
dispatch({ type: 'setStatus', message: 'Link copied to clipboard.' });
window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000);
clearStatusLater();
})
.catch(() => {
dispatch({
@@ -534,7 +544,7 @@ export function HalftoneStudio() {
isError: true,
});
});
}, [exportName, previewDistance, state.settings]);
}, [clearStatusLater, exportName, previewDistance, state.settings]);
const handleCopyHalftoneImage = useCallback(
async (width: number, height: number) => {
@@ -580,7 +590,7 @@ export function HalftoneStudio() {
type: 'setStatus',
message: 'Image copied to clipboard.',
});
window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000);
clearStatusLater();
} catch {
dispatch({
type: 'setStatus',
@@ -589,7 +599,7 @@ export function HalftoneStudio() {
});
}
},
[exportBackground, state.settings.background.color],
[clearStatusLater, exportBackground, state.settings.background.color],
);
useEffect(() => {
@@ -623,75 +633,81 @@ export function HalftoneStudio() {
};
}, [handleCopyHalftoneImage]);
const openFilePicker = useCallback((accept: string) => {
return new Promise<File | null>((resolve) => {
const input = fileInputReference.current;
const openFilePicker = useCallback(
(accept: string) => {
return new Promise<File | null>((resolve) => {
const input = fileInputReference.current;
if (!input) {
resolve(null);
return;
}
if (!input) {
resolve(null);
return;
}
pendingFilePickerReference.current = { resolve };
input.accept = accept;
pendingFilePickerReference.current = { resolve };
input.accept = accept;
const handleWindowFocus = () => {
window.setTimeout(() => {
const pendingPicker = pendingFilePickerReference.current;
const currentInput = fileInputReference.current;
const handleWindowFocus = () => {
timeoutRegistry.schedule(() => {
const pendingPicker = pendingFilePickerReference.current;
const currentInput = fileInputReference.current;
if (!pendingPicker) {
return;
}
if (!pendingPicker) {
return;
}
if (currentInput?.files?.length) {
return;
}
if (currentInput?.files?.length) {
return;
}
pendingFilePickerReference.current = null;
pendingPicker.resolve(null);
}, 300);
};
pendingFilePickerReference.current = null;
pendingPicker.resolve(null);
}, 300);
};
window.addEventListener('focus', handleWindowFocus, { once: true });
input.click();
});
}, []);
window.addEventListener('focus', handleWindowFocus, { once: true });
input.click();
});
},
[timeoutRegistry],
);
const openPresetPicker = useCallback((accept: string) => {
return new Promise<File[]>((resolve) => {
const input = presetFileInputReference.current;
const openPresetPicker = useCallback(
(accept: string) => {
return new Promise<File[]>((resolve) => {
const input = presetFileInputReference.current;
if (!input) {
resolve([]);
return;
}
if (!input) {
resolve([]);
return;
}
pendingPresetPickerReference.current = { resolve };
input.accept = accept;
pendingPresetPickerReference.current = { resolve };
input.accept = accept;
const handleWindowFocus = () => {
window.setTimeout(() => {
const pendingPicker = pendingPresetPickerReference.current;
const currentInput = presetFileInputReference.current;
const handleWindowFocus = () => {
timeoutRegistry.schedule(() => {
const pendingPicker = pendingPresetPickerReference.current;
const currentInput = presetFileInputReference.current;
if (!pendingPicker) {
return;
}
if (!pendingPicker) {
return;
}
if (currentInput?.files?.length) {
return;
}
if (currentInput?.files?.length) {
return;
}
pendingPresetPickerReference.current = null;
pendingPicker.resolve([]);
}, 300);
};
pendingPresetPickerReference.current = null;
pendingPicker.resolve([]);
}, 300);
};
window.addEventListener('focus', handleWindowFocus, { once: true });
input.click();
});
}, []);
window.addEventListener('focus', handleWindowFocus, { once: true });
input.click();
});
},
[timeoutRegistry],
);
const handleFileInputChange = useCallback(() => {
const input = fileInputReference.current;
@@ -844,7 +860,7 @@ export function HalftoneStudio() {
type: 'setStatus',
message: 'Image pasted from clipboard.',
});
window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000);
clearStatusLater();
};
window.addEventListener('paste', handlePaste);
@@ -852,7 +868,7 @@ export function HalftoneStudio() {
return () => {
window.removeEventListener('paste', handlePaste);
};
}, [activateUploadedImage]);
}, [activateUploadedImage, clearStatusLater]);
const handleUploadSource = useCallback(async () => {
const file = await openFilePicker(
@@ -1204,9 +1220,9 @@ export function HalftoneStudio() {
type: 'setStatus',
message: 'Halftone SVG downloaded.',
});
window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000);
clearStatusLater();
},
[buildHalftoneSvg, exportArtifactNames.fileBaseName],
[buildHalftoneSvg, clearStatusLater, exportArtifactNames.fileBaseName],
);
const handleCopyHalftoneSvg = useCallback(
@@ -1239,7 +1255,7 @@ export function HalftoneStudio() {
type: 'setStatus',
message: 'SVG copied to clipboard.',
});
window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000);
clearStatusLater();
return;
} catch {
// Fall through to plain-text clipboard copy below.
@@ -1253,7 +1269,7 @@ export function HalftoneStudio() {
type: 'setStatus',
message: 'SVG markup copied to clipboard as text.',
});
window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000);
clearStatusLater();
return;
} catch {
dispatch({
@@ -1271,7 +1287,7 @@ export function HalftoneStudio() {
isError: true,
});
},
[buildHalftoneSvg],
[buildHalftoneSvg, clearStatusLater],
);
const handleExportHtml = useCallback(async () => {
@@ -1,11 +1,7 @@
import { HalftoneStudio } from '@/app/[locale]/halftone/_components/HalftoneStudio';
import { buildLocalizedMetadata } from '@/lib/seo';
import { buildRouteMetadata } from '@/lib/seo';
export const generateMetadata = buildLocalizedMetadata({
path: '/halftone',
title: 'Halftone Generator | Twenty',
description: 'Interactive halftone generator exported from Twenty.',
});
export const generateMetadata = buildRouteMetadata('halftone');
export default function HalftonePage() {
return <HalftoneStudio />;
@@ -25,7 +25,7 @@ import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import type { ThreeCardsScrollLayoutOptions } from '@/sections/ThreeCards/utils/three-cards-scroll-layout';
import { theme } from '@/theme';
import { buildLocalizedMetadata } from '@/lib/seo';
import { buildRouteMetadata } from '@/lib/seo';
import { styled } from '@linaria/react';
const PARTNER_ILLUSTRATION_CARDS_SCROLL_LAYOUT_OPTIONS: ThreeCardsScrollLayoutOptions =
@@ -45,12 +45,7 @@ const PromoSpacing = styled.div`
}
`;
export const generateMetadata = buildLocalizedMetadata({
path: '/partners',
title: 'Partners | Twenty',
description:
'Join our partner ecosystem and grow with us as we build the #1 open source CRM.',
});
export const generateMetadata = buildRouteMetadata('partners');
export default async function PartnerPage() {
const stats = await fetchCommunityStats();
@@ -19,7 +19,7 @@ import { PricingStateProvider } from '@/sections/Plans/context/PricingStateConte
import { PlanTable } from '@/sections/PlanTable/components';
import { Salesforce } from '@/sections/Salesforce/components';
import { theme } from '@/theme';
import { buildLocalizedMetadata } from '@/lib/seo';
import { buildRouteMetadata } from '@/lib/seo';
import { styled } from '@linaria/react';
const PricingPlansContainer = styled.div`
@@ -34,12 +34,7 @@ const PricingBannerContainer = styled.div`
width: 100%;
`;
export const generateMetadata = buildLocalizedMetadata({
path: '/pricing',
title: 'Pricing | Twenty',
description:
'Plans that scale with your team. Compare tiers of the #1 open source CRM.',
});
export const generateMetadata = buildRouteMetadata('pricing');
export default async function PricingPage() {
const stats = await fetchCommunityStats();
@@ -2,16 +2,11 @@ import { MENU_DATA } from '@/sections/Menu/data';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { LegalDocument } from '@/sections/LegalDocument/components';
import { buildLocalizedMetadata } from '@/lib/seo';
import { buildRouteMetadata } from '@/lib/seo';
import { PrivacyPolicyDocument } from './_components';
export const generateMetadata = buildLocalizedMetadata({
path: '/privacy-policy',
title: 'Privacy Policy | Twenty',
description:
'How Twenty collects, uses, safeguards, and discloses information when you use Twenty.com and related services.',
});
export const generateMetadata = buildRouteMetadata('privacyPolicy');
export default async function PrivacyPolicyPage() {
const stats = await fetchCommunityStats();
@@ -20,14 +20,9 @@ import { Signoff } from '@/sections/Signoff/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import { buildLocalizedMetadata } from '@/lib/seo';
import { buildRouteMetadata } from '@/lib/seo';
export const generateMetadata = buildLocalizedMetadata({
path: '/product',
title: 'Product | Twenty',
description:
'Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.',
});
export const generateMetadata = buildRouteMetadata('product');
export default async function ProductPage() {
const stats = await fetchCommunityStats();
@@ -15,15 +15,10 @@ import { Hero } from '@/sections/Hero/components';
import { Menu } from '@/sections/Menu/components';
import { ReleaseNotes } from '@/sections/ReleaseNotes/components';
import { theme } from '@/theme';
import { buildLocalizedMetadata } from '@/lib/seo';
import { buildRouteMetadata } from '@/lib/seo';
import { Fragment } from 'react';
export const generateMetadata = buildLocalizedMetadata({
path: '/releases',
title: 'Releases | Twenty',
description:
'Discover the newest features and improvements in Twenty, the #1 open source CRM.',
});
export const generateMetadata = buildRouteMetadata('releases');
export default async function ReleasesPage() {
const allNotes = loadLocalReleaseNotes();
@@ -2,16 +2,11 @@ import { MENU_DATA } from '@/sections/Menu/data';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { LegalDocument } from '@/sections/LegalDocument/components';
import { buildLocalizedMetadata } from '@/lib/seo';
import { buildRouteMetadata } from '@/lib/seo';
import { TermsDocument } from './_components';
export const generateMetadata = buildLocalizedMetadata({
path: '/terms',
title: 'Terms of Service | Twenty',
description:
'Terms of Service for Twenty.com PBC, including use of Twenty.com, sub-domains, and related services.',
});
export const generateMetadata = buildRouteMetadata('terms');
export default async function TermsPage() {
const stats = await fetchCommunityStats();
@@ -15,7 +15,7 @@ import { Marquee } from '@/sections/Marquee/components';
import { Menu } from '@/sections/Menu/components';
import { Signoff } from '@/sections/Signoff/components';
import { theme } from '@/theme';
import { buildLocalizedMetadata } from '@/lib/seo';
import { buildRouteMetadata } from '@/lib/seo';
import { css } from '@linaria/core';
const editorialOneIntroClass = css`
@@ -57,12 +57,7 @@ const sectionCrosshairRight = {
lineColor: crosshairLineColor,
};
export const generateMetadata = buildLocalizedMetadata({
path: '/why-twenty',
title: 'Why Twenty | Twenty',
description:
'Most packaged software makes companies more similar. Learn why the future of CRM is built, not bought.',
});
export const generateMetadata = buildRouteMetadata('whyTwenty');
export default async function WhyTwentyPage() {
const stats = await fetchCommunityStats();
@@ -156,11 +151,6 @@ export default async function WhyTwentyPage() {
/>
</Editorial.Root>
{/* <Quote.Root backgroundColor={theme.colors.secondary.background[80]}>
<Quote.Visual illustration={QUOTE_DATA.illustration} />
<Quote.Heading segments={QUOTE_DATA.heading} />
</Quote.Root>*/}
<Editorial.Root
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
@@ -0,0 +1,14 @@
import robots from '@/app/robots';
describe('robots', () => {
it('disallows API and non-indexed utility routes', () => {
const rules = robots().rules;
const rule = Array.isArray(rules) ? rules[0] : rules;
expect(rule).toMatchObject({
allow: '/',
disallow: ['/api/', '/halftone', '/enterprise/activate'],
userAgent: '*',
});
});
});
@@ -0,0 +1,29 @@
import sitemap from '@/app/sitemap';
const pathnameOf = (url: string): string => new URL(url).pathname;
describe('sitemap', () => {
it('emits only indexed public website routes', () => {
const entries = sitemap();
const pathnames = entries.map((entry) => pathnameOf(entry.url));
expect(pathnames).toContain('/');
expect(pathnames).toContain('/product');
expect(pathnames).toContain('/customers/9dots');
expect(pathnames).not.toContain('/fr-FR/product');
expect(pathnames).not.toContain('/halftone');
expect(pathnames).not.toContain('/enterprise/activate');
});
it('emits hreflang alternates only for published website locales', () => {
const productEntry = sitemap().find(
(entry) => pathnameOf(entry.url) === '/product',
);
expect(productEntry?.alternates?.languages).toMatchObject({
en: expect.stringMatching(/\/product$/),
'x-default': expect.stringMatching(/\/product$/),
});
expect(productEntry?.alternates?.languages).not.toHaveProperty('fr-FR');
});
});
@@ -1,8 +1,10 @@
import type { MetadataRoute } from 'next';
import { getSiteUrl } from '@/lib/seo';
import { getRobotsDisallowedRoutePaths } from '@/lib/website-routing';
const SITE_URL = getSiteUrl();
const ALWAYS_DISALLOW = ['/api/'] as const;
export default function robots(): MetadataRoute.Robots {
return {
@@ -10,13 +12,7 @@ export default function robots(): MetadataRoute.Robots {
{
userAgent: '*',
allow: '/',
disallow: [
'/api/',
'/halftone',
'/*/halftone',
'/enterprise/activate',
'/*/enterprise/activate',
],
disallow: [...ALWAYS_DISALLOW, ...getRobotsDisallowedRoutePaths()],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
+2 -26
View File
@@ -1,30 +1,12 @@
import type { MetadataRoute } from 'next';
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
import { PUBLIC_APP_LOCALE_LIST } from '@/lib/i18n';
import { getSiteUrl } from '@/lib/seo';
import { getIndexedWebsiteRoutes } from '@/lib/website-routing';
const SITE_URL = getSiteUrl();
type StaticRoute = {
path: string;
changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'];
priority: number;
};
const STATIC_ROUTES: readonly StaticRoute[] = [
{ path: '/', changeFrequency: 'weekly', priority: 1.0 },
{ path: '/why-twenty', changeFrequency: 'monthly', priority: 0.8 },
{ path: '/product', changeFrequency: 'monthly', priority: 0.8 },
{ path: '/pricing', changeFrequency: 'monthly', priority: 0.9 },
{ path: '/partners', changeFrequency: 'monthly', priority: 0.7 },
{ path: '/releases', changeFrequency: 'weekly', priority: 0.7 },
{ path: '/customers', changeFrequency: 'monthly', priority: 0.7 },
{ path: '/privacy-policy', changeFrequency: 'yearly', priority: 0.3 },
{ path: '/terms', changeFrequency: 'yearly', priority: 0.3 },
];
const buildLocalizedUrl = (locale: AppLocale, path: string): string => {
const prefix = locale === SOURCE_LOCALE ? '' : `/${locale}`;
const tail = path === '/' ? '' : path;
@@ -53,14 +35,8 @@ const localize = (
}));
export default function sitemap(): MetadataRoute.Sitemap {
const staticEntries = STATIC_ROUTES.flatMap(
return getIndexedWebsiteRoutes().flatMap(
({ path, changeFrequency, priority }) =>
localize(path, changeFrequency, priority),
);
const caseStudyEntries = CASE_STUDY_CATALOG_ENTRIES.flatMap((entry) =>
localize(entry.href, 'yearly', 0.5),
);
return [...staticEntries, ...caseStudyEntries];
}
@@ -0,0 +1,68 @@
import {
easeOutCubic,
getAnimatedNumberValue,
} from '@/lib/animation/animated-number';
describe('animated number', () => {
it('returns the starting value before time has elapsed', () => {
expect(
getAnimatedNumberValue({
elapsedMs: 0,
from: 10,
target: 110,
}),
).toBe(10);
});
it('applies cubic easing to the interpolated value', () => {
expect(
getAnimatedNumberValue({
durationMs: 500,
elapsedMs: 250,
from: 0,
target: 100,
}),
).toBe(88);
});
it('returns the target value after the duration has elapsed', () => {
expect(
getAnimatedNumberValue({
durationMs: 500,
elapsedMs: 750,
from: 0,
target: 100,
}),
).toBe(100);
});
it('returns the target value when duration is not positive', () => {
expect(
getAnimatedNumberValue({
durationMs: 0,
elapsedMs: 0,
from: 0,
target: 100,
}),
).toBe(100);
});
it('supports custom easing and rounding', () => {
expect(
getAnimatedNumberValue({
durationMs: 100,
easing: (progress) => progress,
elapsedMs: 50,
from: 0,
round: Math.floor,
target: 11,
}),
).toBe(5);
});
it('clamps easing input to a unit interval', () => {
expect(easeOutCubic(Number.NaN)).toBe(0);
expect(easeOutCubic(-1)).toBe(0);
expect(easeOutCubic(2)).toBe(1);
});
});
@@ -0,0 +1,102 @@
import { createAnimationFrameLoop } from '@/lib/animation/animation-frame-loop';
function createAnimationFrameScheduler() {
let nextHandle = 1;
const callbacks = new Map<number, FrameRequestCallback>();
return {
callbacks,
cancelAnimationFrame: jest.fn((handle: number) => {
callbacks.delete(handle);
}),
requestAnimationFrame: jest.fn((callback: FrameRequestCallback) => {
const handle = nextHandle;
nextHandle += 1;
callbacks.set(handle, callback);
return handle;
}),
runFrame: (handle: number, timestamp = 0) => {
const callback = callbacks.get(handle);
callbacks.delete(handle);
callback?.(timestamp);
},
};
}
describe('createAnimationFrameLoop', () => {
it('does not schedule duplicate frames when started repeatedly', () => {
const scheduler = createAnimationFrameScheduler();
const loop = createAnimationFrameLoop({
cancelAnimationFrame: scheduler.cancelAnimationFrame,
onFrame: jest.fn(() => false),
requestAnimationFrame: scheduler.requestAnimationFrame,
});
loop.start();
loop.start();
expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(1);
});
it('keeps scheduling while onFrame does not return false', () => {
const scheduler = createAnimationFrameScheduler();
const onFrame = jest
.fn()
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(false);
const loop = createAnimationFrameLoop({
cancelAnimationFrame: scheduler.cancelAnimationFrame,
onFrame,
requestAnimationFrame: scheduler.requestAnimationFrame,
});
loop.start();
scheduler.runFrame(1, 16);
expect(onFrame).toHaveBeenCalledWith(16);
expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(2);
scheduler.runFrame(2, 32);
expect(onFrame).toHaveBeenCalledWith(32);
expect(loop.isRunning()).toBe(false);
});
it('cancels the pending frame on stop', () => {
const scheduler = createAnimationFrameScheduler();
const loop = createAnimationFrameLoop({
cancelAnimationFrame: scheduler.cancelAnimationFrame,
onFrame: jest.fn(),
requestAnimationFrame: scheduler.requestAnimationFrame,
});
loop.start();
loop.stop();
expect(scheduler.cancelAnimationFrame).toHaveBeenCalledWith(1);
expect(loop.isRunning()).toBe(false);
});
it('does not reschedule when stopped during onFrame', () => {
const scheduler = createAnimationFrameScheduler();
let loop: ReturnType<typeof createAnimationFrameLoop>;
const onFrame = jest.fn(() => {
loop.stop();
});
loop = createAnimationFrameLoop({
cancelAnimationFrame: scheduler.cancelAnimationFrame,
onFrame,
requestAnimationFrame: scheduler.requestAnimationFrame,
});
loop.start();
scheduler.runFrame(1, 16);
expect(onFrame).toHaveBeenCalledWith(16);
expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(1);
expect(scheduler.callbacks.size).toBe(0);
expect(loop.isRunning()).toBe(false);
});
});
@@ -0,0 +1,51 @@
export type AnimatedNumberEasing = (progress: number) => number;
export type AnimatedNumberRounder = (value: number) => number;
export type GetAnimatedNumberValueParameters = {
durationMs?: number;
easing?: AnimatedNumberEasing;
elapsedMs: number;
from: number;
round?: AnimatedNumberRounder;
target: number;
};
export const DEFAULT_ANIMATED_NUMBER_DURATION_MS = 500;
const DEFAULT_ANIMATED_NUMBER_ROUNDER: AnimatedNumberRounder = Math.round;
function clampUnitInterval(value: number): number {
if (Number.isNaN(value) || value <= 0) {
return 0;
}
if (value >= 1) {
return 1;
}
return value;
}
export const easeOutCubic: AnimatedNumberEasing = (progress) => {
const clampedProgress = clampUnitInterval(progress);
return 1 - (1 - clampedProgress) ** 3;
};
export function getAnimatedNumberValue({
durationMs = DEFAULT_ANIMATED_NUMBER_DURATION_MS,
easing = easeOutCubic,
elapsedMs,
from,
round = DEFAULT_ANIMATED_NUMBER_ROUNDER,
target,
}: GetAnimatedNumberValueParameters): number {
if (!Number.isFinite(durationMs) || durationMs <= 0) {
return round(target);
}
const progress = clampUnitInterval(elapsedMs / durationMs);
const easedProgress = clampUnitInterval(easing(progress));
return round(from + (target - from) * easedProgress);
}
@@ -0,0 +1,83 @@
export type AnimationFrameLoopController = {
isRunning: () => boolean;
start: () => void;
stop: () => void;
};
export type CreateAnimationFrameLoopOptions = {
cancelAnimationFrame?: (handle: number) => void;
onFrame: (timestamp: number) => boolean | void;
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
};
const requestAnimationFrameFromWindow = (callback: FrameRequestCallback) =>
window.requestAnimationFrame(callback);
const cancelAnimationFrameFromWindow = (handle: number) =>
window.cancelAnimationFrame(handle);
export function createAnimationFrameLoop({
cancelAnimationFrame = cancelAnimationFrameFromWindow,
onFrame,
requestAnimationFrame = requestAnimationFrameFromWindow,
}: CreateAnimationFrameLoopOptions): AnimationFrameLoopController {
let animationFrameId: number | null = null;
let isLoopRunning = false;
let runToken = 0;
const stop = () => {
if (!isLoopRunning && animationFrameId === null) {
return;
}
isLoopRunning = false;
runToken += 1;
if (animationFrameId === null) {
return;
}
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
};
const scheduleNextFrame = (token: number) => {
animationFrameId = requestAnimationFrame((timestamp) => {
animationFrameId = null;
if (!isLoopRunning || token !== runToken) {
return;
}
if (onFrame(timestamp) === false) {
if (token === runToken) {
isLoopRunning = false;
}
return;
}
if (!isLoopRunning || token !== runToken) {
return;
}
scheduleNextFrame(token);
});
};
const start = () => {
if (isLoopRunning) {
return;
}
isLoopRunning = true;
runToken += 1;
scheduleNextFrame(runToken);
};
return {
isRunning: () => isLoopRunning,
start,
stop,
};
}
@@ -0,0 +1,17 @@
export {
createAnimationFrameLoop,
type AnimationFrameLoopController,
type CreateAnimationFrameLoopOptions,
} from './animation-frame-loop';
export {
DEFAULT_ANIMATED_NUMBER_DURATION_MS,
easeOutCubic,
getAnimatedNumberValue,
type AnimatedNumberEasing,
type AnimatedNumberRounder,
type GetAnimatedNumberValueParameters,
} from './animated-number';
export {
useAnimatedNumber,
type UseAnimatedNumberOptions,
} from './use-animated-number';
@@ -0,0 +1,98 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useLatestRef } from '@/lib/react';
import {
DEFAULT_ANIMATED_NUMBER_DURATION_MS,
easeOutCubic,
getAnimatedNumberValue,
type AnimatedNumberEasing,
type AnimatedNumberRounder,
} from './animated-number';
import { createAnimationFrameLoop } from './animation-frame-loop';
export type UseAnimatedNumberOptions = {
disabled?: boolean;
durationMs?: number;
easing?: AnimatedNumberEasing;
round?: AnimatedNumberRounder;
};
const DEFAULT_ANIMATED_NUMBER_ROUNDER: AnimatedNumberRounder = Math.round;
function canScheduleAnimationFrame() {
return (
typeof window !== 'undefined' &&
typeof window.requestAnimationFrame === 'function' &&
typeof window.cancelAnimationFrame === 'function'
);
}
function getAnimationTimestamp() {
return typeof performance === 'undefined' ? Date.now() : performance.now();
}
export function useAnimatedNumber(
target: number,
{
disabled = false,
durationMs = DEFAULT_ANIMATED_NUMBER_DURATION_MS,
easing = easeOutCubic,
round = DEFAULT_ANIMATED_NUMBER_ROUNDER,
}: UseAnimatedNumberOptions = {},
): number {
const [display, setDisplay] = useState(() => round(target));
const displayRef = useRef(display);
const easingRef = useLatestRef(easing);
const roundRef = useLatestRef(round);
useEffect(() => {
const roundedTarget = roundRef.current(target);
const from = displayRef.current;
if (
disabled ||
!Number.isFinite(durationMs) ||
durationMs <= 0 ||
from === roundedTarget ||
!canScheduleAnimationFrame()
) {
displayRef.current = roundedTarget;
setDisplay(roundedTarget);
return;
}
const startTime = getAnimationTimestamp();
const animationLoop = createAnimationFrameLoop({
onFrame: (now) => {
const elapsedMs = now - startTime;
const nextDisplay = getAnimatedNumberValue({
durationMs,
easing: easingRef.current,
elapsedMs,
from,
round: roundRef.current,
target,
});
if (displayRef.current !== nextDisplay) {
displayRef.current = nextDisplay;
setDisplay(nextDisplay);
}
return elapsedMs < durationMs;
},
});
animationLoop.start();
return () => {
animationLoop.stop();
};
}, [disabled, durationMs, easingRef, roundRef, target]);
return display;
}
@@ -0,0 +1,113 @@
import {
observeElementSize,
observeElementsSize,
} from '../observe-element-size';
type GlobalWithResizeObserver = typeof globalThis & {
ResizeObserver?: typeof ResizeObserver;
window?: Pick<Window, 'addEventListener' | 'removeEventListener'>;
};
const getGlobalWithResizeObserver = () =>
globalThis as GlobalWithResizeObserver;
describe('observeElementSize', () => {
const originalResizeObserver = getGlobalWithResizeObserver().ResizeObserver;
const originalWindow = getGlobalWithResizeObserver().window;
afterEach(() => {
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: originalResizeObserver,
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: originalWindow,
});
jest.restoreAllMocks();
});
it('uses ResizeObserver when available', () => {
const disconnect = jest.fn();
const observe = jest.fn();
const element = {} as Element;
class MockResizeObserver {
observe = observe;
disconnect = disconnect;
}
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: MockResizeObserver,
});
const stopObserving = observeElementSize(element, jest.fn());
expect(observe).toHaveBeenCalledWith(element);
stopObserving();
expect(disconnect).toHaveBeenCalledTimes(1);
});
it('observes multiple elements through one ResizeObserver', () => {
const disconnect = jest.fn();
const observe = jest.fn();
const elements = [{} as Element, {} as Element];
class MockResizeObserver {
observe = observe;
disconnect = disconnect;
}
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: MockResizeObserver,
});
const stopObserving = observeElementsSize(elements, jest.fn());
expect(observe).toHaveBeenCalledTimes(2);
expect(observe).toHaveBeenNthCalledWith(1, elements[0]);
expect(observe).toHaveBeenNthCalledWith(2, elements[1]);
stopObserving();
expect(disconnect).toHaveBeenCalledTimes(1);
});
it('falls back to window resize events when ResizeObserver is unavailable', () => {
const onResize = jest.fn();
const element = {} as Element;
const addEventListener = jest.fn();
const removeEventListener = jest.fn();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener,
removeEventListener,
},
});
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: undefined,
});
const stopObserving = observeElementSize(element, onResize);
expect(addEventListener).toHaveBeenCalledWith('resize', onResize);
const resizeListener = addEventListener.mock.calls[0]?.[1] as () => void;
resizeListener();
expect(onResize).toHaveBeenCalledTimes(1);
stopObserving();
expect(removeEventListener).toHaveBeenCalledWith('resize', onResize);
});
});
@@ -0,0 +1,66 @@
import { observeElementVisibility } from '../observe-element-visibility';
type GlobalWithIntersectionObserver = typeof globalThis & {
IntersectionObserver?: typeof IntersectionObserver;
};
const getGlobalWithIntersectionObserver = () =>
globalThis as GlobalWithIntersectionObserver;
describe('observeElementVisibility', () => {
const originalIntersectionObserver =
getGlobalWithIntersectionObserver().IntersectionObserver;
afterEach(() => {
Object.defineProperty(globalThis, 'IntersectionObserver', {
configurable: true,
value: originalIntersectionObserver,
});
jest.restoreAllMocks();
});
it('uses IntersectionObserver when available', () => {
const disconnect = jest.fn();
const observe = jest.fn();
const element = {} as Element;
class MockIntersectionObserver {
observe = observe;
disconnect = disconnect;
}
Object.defineProperty(globalThis, 'IntersectionObserver', {
configurable: true,
value: MockIntersectionObserver,
});
const stopObserving = observeElementVisibility(element, jest.fn(), {
rootMargin: '100px',
});
expect(observe).toHaveBeenCalledWith(element);
stopObserving();
expect(disconnect).toHaveBeenCalledTimes(1);
});
it('falls back to a visible state when IntersectionObserver is unavailable', () => {
const onVisibilityChange = jest.fn();
Object.defineProperty(globalThis, 'IntersectionObserver', {
configurable: true,
value: undefined,
});
const stopObserving = observeElementVisibility(
{} as Element,
onVisibilityChange,
);
expect(onVisibilityChange).toHaveBeenCalledWith(true);
stopObserving();
});
});
@@ -0,0 +1,46 @@
import { getHorizontalWheelScrollLeft } from '../use-horizontal-drag-scroll';
describe('getHorizontalWheelScrollLeft', () => {
it('maps vertical wheel movement to horizontal scroll', () => {
expect(
getHorizontalWheelScrollLeft(
{ clientWidth: 200, scrollLeft: 40, scrollWidth: 500 },
{ deltaX: 0, deltaY: 80 },
),
).toBe(120);
});
it('ignores mostly horizontal wheel movement', () => {
expect(
getHorizontalWheelScrollLeft(
{ clientWidth: 200, scrollLeft: 40, scrollWidth: 500 },
{ deltaX: 80, deltaY: 20 },
),
).toBeNull();
});
it('clamps scroll to the available range', () => {
expect(
getHorizontalWheelScrollLeft(
{ clientWidth: 200, scrollLeft: 260, scrollWidth: 500 },
{ deltaX: 0, deltaY: 80 },
),
).toBe(300);
expect(
getHorizontalWheelScrollLeft(
{ clientWidth: 200, scrollLeft: 20, scrollWidth: 500 },
{ deltaX: 0, deltaY: -80 },
),
).toBe(0);
});
it('returns null when the scroll position would not materially change', () => {
expect(
getHorizontalWheelScrollLeft(
{ clientWidth: 200, scrollLeft: 0, scrollWidth: 500 },
{ deltaX: 0, deltaY: -10 },
),
).toBeNull();
});
});
@@ -0,0 +1,29 @@
export const observeElementsSize = (
elements: ReadonlyArray<Element>,
onResize: () => void,
): (() => void) => {
if (elements.length === 0) {
return () => {};
}
if (typeof ResizeObserver === 'function') {
const resizeObserver = new ResizeObserver(() => onResize());
elements.forEach((element) => resizeObserver.observe(element));
return () => resizeObserver.disconnect();
}
if (typeof window === 'undefined') {
return () => {};
}
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
};
export const observeElementSize = (
element: Element,
onResize: () => void,
): (() => void) => observeElementsSize([element], onResize);
@@ -0,0 +1,25 @@
export type ObserveElementVisibilityOptions = IntersectionObserverInit & {
fallbackIsVisible?: boolean;
};
export const observeElementVisibility = (
element: Element,
onVisibilityChange: (isVisible: boolean) => void,
options: ObserveElementVisibilityOptions = {},
): (() => void) => {
const { fallbackIsVisible = true, ...intersectionObserverOptions } = options;
if (typeof IntersectionObserver === 'function') {
const intersectionObserver = new IntersectionObserver((entries) => {
onVisibilityChange(entries.some((entry) => entry.isIntersecting));
}, intersectionObserverOptions);
intersectionObserver.observe(element);
return () => intersectionObserver.disconnect();
}
onVisibilityChange(fallbackIsVisible);
return () => {};
};
@@ -0,0 +1,165 @@
import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from 'react';
type DragState = {
active: boolean;
pointerId: number;
startScrollLeft: number;
startX: number;
};
type UseHorizontalDragScrollOptions = {
wheelScrollsHorizontally?: boolean;
};
export type HorizontalWheelScrollState = {
clientWidth: number;
scrollLeft: number;
scrollWidth: number;
};
export type HorizontalWheelDelta = {
deltaX: number;
deltaY: number;
};
const EMPTY_DRAG_STATE: DragState = {
active: false,
pointerId: -1,
startScrollLeft: 0,
startX: 0,
};
const releasePointerCapture = (element: HTMLElement, pointerId: number) => {
if (pointerId < 0 || !element.hasPointerCapture(pointerId)) {
return;
}
element.releasePointerCapture(pointerId);
};
export const getHorizontalWheelScrollLeft = (
state: HorizontalWheelScrollState,
delta: HorizontalWheelDelta,
): number | null => {
if (Math.abs(delta.deltaY) <= Math.abs(delta.deltaX)) {
return null;
}
const maxScrollLeft = Math.max(state.scrollWidth - state.clientWidth, 0);
const nextScrollLeft = Math.min(
Math.max(state.scrollLeft + delta.deltaY, 0),
maxScrollLeft,
);
return Math.abs(nextScrollLeft - state.scrollLeft) < 0.5
? null
: nextScrollLeft;
};
export function useHorizontalDragScroll<TElement extends HTMLElement>({
wheelScrollsHorizontally = false,
}: UseHorizontalDragScrollOptions = {}) {
const viewportRef = useRef<TElement>(null);
const dragRef = useRef<DragState>(EMPTY_DRAG_STATE);
const [dragging, setDragging] = useState(false);
const endDragging = () => {
const viewport = viewportRef.current;
const pointerId = dragRef.current.pointerId;
if (viewport) {
releasePointerCapture(viewport, pointerId);
}
dragRef.current = EMPTY_DRAG_STATE;
setDragging(false);
};
const handlePointerDown = (event: ReactPointerEvent<TElement>) => {
const viewport = viewportRef.current;
if (event.pointerType !== 'mouse' || event.button !== 0 || !viewport) {
return;
}
dragRef.current = {
active: true,
pointerId: event.pointerId,
startScrollLeft: viewport.scrollLeft,
startX: event.clientX,
};
viewport.setPointerCapture(event.pointerId);
setDragging(true);
event.preventDefault();
};
const handlePointerMove = (event: ReactPointerEvent<TElement>) => {
const viewport = viewportRef.current;
const drag = dragRef.current;
if (!drag.active || !viewport) {
return;
}
viewport.scrollLeft = drag.startScrollLeft - (event.clientX - drag.startX);
};
const handlePointerUp = (event: ReactPointerEvent<TElement>) => {
if (dragRef.current.pointerId !== event.pointerId) {
return;
}
endDragging();
};
useEffect(() => {
const viewport = viewportRef.current;
if (!wheelScrollsHorizontally || !viewport) {
return;
}
const handleWheel = (event: WheelEvent) => {
const nextScrollLeft = getHorizontalWheelScrollLeft(
{
clientWidth: viewport.clientWidth,
scrollLeft: viewport.scrollLeft,
scrollWidth: viewport.scrollWidth,
},
{
deltaX: event.deltaX,
deltaY: event.deltaY,
},
);
if (nextScrollLeft === null) {
return;
}
viewport.scrollLeft = nextScrollLeft;
event.preventDefault();
};
viewport.addEventListener('wheel', handleWheel, { passive: false });
return () => {
viewport.removeEventListener('wheel', handleWheel);
};
}, [wheelScrollsHorizontally]);
return {
dragging,
onPointerCancel: endDragging,
onPointerDown: handlePointerDown,
onPointerLeave: endDragging,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
viewportRef,
};
}
@@ -25,6 +25,24 @@ describe('halftone footprint helpers', () => {
});
});
it('computes the visible covered image rect', () => {
expect(
getContainedImageRect({
imageFit: 'cover',
imageHeight: 500,
imageWidth: 1000,
viewportHeight: 800,
viewportWidth: 800,
zoom: 1,
}),
).toEqual({
x: 0,
y: 0,
width: 800,
height: 800,
});
});
it('derives image footprint scale from preview distance', () => {
expect(
getImageFootprintScale({
@@ -82,6 +100,7 @@ return { getContainedImageRect, getImageFootprintScale, getMeshFootprintScale };
};
const imageArgs = {
imageFit: 'cover' as const,
imageHeight: 1000,
imageWidth: 1600,
previewDistance: 6,
@@ -0,0 +1,85 @@
import { DEFAULT_HALFTONE_SETTINGS } from '../state';
import {
applySpringStep,
createHalftoneInteractionState,
resetHalftoneInteractionState,
} from '../interaction-state';
describe('halftone interaction state', () => {
it('hydrates pose fields while defaulting transient pointer state', () => {
expect(
createHalftoneInteractionState({
autoElapsed: 10,
rotateElapsed: 2,
rotationX: 0.1,
rotationY: 0.2,
rotationZ: 0.3,
targetRotationX: 0.4,
targetRotationY: 0.5,
}),
).toMatchObject({
activePointerId: null,
autoElapsed: 10,
mouseX: 0.5,
pointerInside: false,
rotateElapsed: 2,
rotationX: 0.1,
rotationY: 0.2,
rotationZ: 0.3,
targetRotationX: 0.4,
targetRotationY: 0.5,
});
});
it('applies one damped spring integration step', () => {
expect(
applySpringStep({
current: 0,
damping: 0.5,
strength: 0.2,
target: 10,
velocity: 1,
}),
).toEqual({
value: 1.5,
velocity: 1.5,
});
});
it('resets pointer and velocity fields without always resetting elapsed auto rotation', () => {
const interaction = createHalftoneInteractionState({ autoElapsed: 10 });
interaction.activePointerId = 7;
interaction.dragging = true;
interaction.mouseX = 0.1;
interaction.pointerVelocityX = 2;
interaction.rotationVelocityY = 3;
interaction.targetRotationX = 4;
resetHalftoneInteractionState(interaction, {
...DEFAULT_HALFTONE_SETTINGS.animation,
autoRotateEnabled: false,
});
expect(interaction).toMatchObject({
activePointerId: null,
autoElapsed: 10,
dragging: false,
mouseX: 0.5,
pointerVelocityX: 0,
rotationVelocityY: 0,
targetRotationX: 0,
});
});
it('resets auto rotation elapsed time when auto rotation is enabled', () => {
const interaction = createHalftoneInteractionState({ autoElapsed: 10 });
resetHalftoneInteractionState(interaction, {
...DEFAULT_HALFTONE_SETTINGS.animation,
autoRotateEnabled: true,
});
expect(interaction.autoElapsed).toBe(0);
});
});
@@ -7,7 +7,10 @@ export interface HalftoneRect {
y: number;
}
export type HalftoneImageFit = 'contain' | 'cover';
export interface ImageFootprintScaleArgs {
imageFit?: HalftoneImageFit;
imageHeight: number;
imageWidth: number;
previewDistance: number;
@@ -82,12 +85,14 @@ export function getImagePreviewZoom(previewDistance: number) {
}
export function getContainedImageRect({
imageFit = 'contain',
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom,
}: {
imageFit?: HalftoneImageFit;
imageHeight: number;
imageWidth: number;
viewportHeight: number;
@@ -110,9 +115,17 @@ export function getContainedImageRect({
let fittedHeight = viewportHeight;
if (imageAspect > viewAspect) {
fittedHeight = viewportWidth / imageAspect;
if (imageFit === 'cover') {
fittedWidth = viewportHeight * imageAspect;
} else {
fittedHeight = viewportWidth / imageAspect;
}
} else {
fittedWidth = viewportHeight * imageAspect;
if (imageFit === 'cover') {
fittedHeight = viewportWidth / imageAspect;
} else {
fittedWidth = viewportHeight * imageAspect;
}
}
const scaledWidth = fittedWidth * zoom;
@@ -145,6 +158,7 @@ export function getFootprintScaleFromRects(
}
export function getImageFootprintScale({
imageFit = 'contain',
imageHeight,
imageWidth,
previewDistance,
@@ -152,6 +166,7 @@ export function getImageFootprintScale({
viewportWidth,
}: ImageFootprintScaleArgs) {
const currentRect = getContainedImageRect({
imageFit,
imageHeight,
imageWidth,
viewportHeight,
@@ -159,6 +174,7 @@ export function getImageFootprintScale({
zoom: getImagePreviewZoom(previewDistance),
});
const referenceRect = getContainedImageRect({
imageFit,
imageHeight,
imageWidth,
viewportHeight,
@@ -312,6 +328,7 @@ function getImagePreviewZoom(previewDistance) {
}
function getContainedImageRect({
imageFit = 'contain',
imageHeight,
imageWidth,
viewportHeight,
@@ -334,9 +351,17 @@ function getContainedImageRect({
let fittedHeight = viewportHeight;
if (imageAspect > viewAspect) {
fittedHeight = viewportWidth / imageAspect;
if (imageFit === 'cover') {
fittedWidth = viewportHeight * imageAspect;
} else {
fittedHeight = viewportWidth / imageAspect;
}
} else {
fittedWidth = viewportHeight * imageAspect;
if (imageFit === 'cover') {
fittedHeight = viewportWidth / imageAspect;
} else {
fittedWidth = viewportHeight * imageAspect;
}
}
const scaledWidth = fittedWidth * zoom;
@@ -369,6 +394,7 @@ function getFootprintScaleFromRects(currentRect, referenceRect) {
}
function getImageFootprintScale({
imageFit = 'contain',
imageHeight,
imageWidth,
previewDistance,
@@ -376,6 +402,7 @@ function getImageFootprintScale({
viewportWidth,
}) {
const currentRect = getContainedImageRect({
imageFit,
imageHeight,
imageWidth,
viewportHeight,
@@ -383,6 +410,7 @@ function getImageFootprintScale({
zoom: getImagePreviewZoom(previewDistance),
});
const referenceRect = getContainedImageRect({
imageFit,
imageHeight,
imageWidth,
viewportHeight,
@@ -19,6 +19,13 @@ interface HalftoneGeometrySpec {
type GeometryCacheEntry = THREE.BufferGeometry | Promise<THREE.BufferGeometry>;
export type ImportedGeometryNormalizationOptions = {
postRotateZ?: number;
useLegacyNormalization?: boolean;
};
const LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET = 2.75;
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
if (geometries.length === 1) {
return geometries[0];
@@ -117,7 +124,12 @@ function mergeGeometries(geometries: THREE.BufferGeometry[]) {
const EMPTY_TEXTURE_DATA_URL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
function normalizeImportedGeometry(
geometry: THREE.BufferGeometry,
options: ImportedGeometryNormalizationOptions = {},
) {
const { postRotateZ = 0, useLegacyNormalization = false } = options;
geometry.computeBoundingBox();
let boundingBox = geometry.boundingBox;
@@ -128,20 +140,25 @@ function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
boundingBox?.getSize(size);
geometry.translate(-center.x, -center.y, -center.z);
const dimensions = [size.x, size.y, size.z];
const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
if (!useLegacyNormalization) {
const dimensions = [size.x, size.y, size.z];
const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
if (thinnestAxis === 0) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
} else if (thinnestAxis === 1) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
if (thinnestAxis === 0) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
} else if (thinnestAxis === 1) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
}
}
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
const radius = geometry.boundingSphere?.radius || 1;
const scale = 1.6 / radius;
const scale = useLegacyNormalization
? LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET /
Math.max(size.x, size.y, size.z, 0.001)
: 1.6 / radius;
geometry.scale(scale, scale, scale);
geometry.computeBoundingBox();
@@ -150,6 +167,10 @@ function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
boundingBox?.getCenter(center);
geometry.translate(-center.x, -center.y, -center.z);
if (postRotateZ !== 0) {
geometry.rotateZ(postRotateZ);
}
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
@@ -166,7 +187,11 @@ function createLoadingManager() {
return loadingManager;
}
function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) {
function extractMergedGeometry(
root: THREE.Object3D,
emptyMessage: string,
geometryOptions?: ImportedGeometryNormalizationOptions,
) {
root.updateMatrixWorld(true);
const geometries: THREE.BufferGeometry[] = [];
@@ -190,13 +215,17 @@ function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) {
throw new Error(emptyMessage);
}
return normalizeImportedGeometry(mergeGeometries(geometries));
return normalizeImportedGeometry(
mergeGeometries(geometries),
geometryOptions,
);
}
function parseFbxGeometry(
buffer: ArrayBuffer,
resourcePath: string,
label: string,
geometryOptions?: ImportedGeometryNormalizationOptions,
) {
const originalWarn = console.warn;
@@ -217,6 +246,7 @@ function parseFbxGeometry(
return extractMergedGeometry(
root,
`${label} did not contain any mesh geometry.`,
geometryOptions,
);
} finally {
console.warn = originalWarn;
@@ -227,6 +257,7 @@ function parseGlbGeometry(
buffer: ArrayBuffer,
resourcePath: string,
label: string,
geometryOptions?: ImportedGeometryNormalizationOptions,
) {
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
@@ -244,6 +275,7 @@ function parseGlbGeometry(
extractMergedGeometry(
gltf.scene,
`${label} did not contain any mesh geometry.`,
geometryOptions,
),
);
} catch (error) {
@@ -275,6 +307,7 @@ export async function loadImportedGeometryFromUrl(
loader: HalftoneModelLoader,
modelUrl: string,
label: string,
geometryOptions?: ImportedGeometryNormalizationOptions,
) {
const response = await fetch(modelUrl);
@@ -285,10 +318,10 @@ export async function loadImportedGeometryFromUrl(
const buffer = await response.arrayBuffer();
if (loader === 'fbx') {
return parseFbxGeometry(buffer, '', label);
return parseFbxGeometry(buffer, '', label, geometryOptions);
}
return parseGlbGeometry(buffer, '', label);
return parseGlbGeometry(buffer, '', label, geometryOptions);
}
function makePolarShape(
@@ -4,8 +4,10 @@ import {
getImageFootprintScale,
getImagePreviewZoom,
getMeshFootprintScale,
type HalftoneImageFit,
VIRTUAL_RENDER_HEIGHT,
} from '@/lib/halftone/footprint';
import { observeElementSize } from '@/lib/dom/observe-element-size';
import {
applyHalftoneMaterialSettings,
createHalftoneMaterial,
@@ -15,11 +17,24 @@ import {
type HalftoneTransmissionMaterial,
renderHalftoneMaterialScene,
} from '@/lib/halftone/materials';
import {
applySpringStep,
createHalftoneInteractionState,
resetHalftoneInteractionState,
type HalftoneInteractionState,
} from '@/lib/halftone/interaction-state';
import type {
HalftoneExportPose,
HalftoneStudioSettings,
} from '@/lib/halftone/state';
import { createSiteWebGlRenderer } from '@/lib/visual-runtime';
import { runCleanupTasks } from '@/lib/lifecycle/run-cleanup-tasks';
import { useLatestRef } from '@/lib/react';
import {
createSiteWebGlRenderer,
createVisualRenderLoop,
evaluateWebGlPolicy,
type VisualRenderLoop,
} from '@/lib/visual-runtime';
import { styled } from '@linaria/react';
import { type MutableRefObject, useEffect, useRef } from 'react';
import * as THREE from 'three';
@@ -73,6 +88,7 @@ const imagePassthroughFragmentShader = `
uniform vec2 viewportSize;
uniform float zoom;
uniform float contrast;
uniform float imageFit;
varying vec2 vUv;
@@ -84,10 +100,18 @@ const imagePassthroughFragmentShader = `
if (imageAspect > viewAspect) {
float scale = viewAspect / imageAspect;
uv.y = (uv.y - 0.5) / scale + 0.5;
if (imageFit > 0.5) {
uv.x = (uv.x - 0.5) * scale + 0.5;
} else {
uv.y = (uv.y - 0.5) / scale + 0.5;
}
} else {
float scale = imageAspect / viewAspect;
uv.x = (uv.x - 0.5) / scale + 0.5;
if (imageFit > 0.5) {
uv.y = (uv.y - 0.5) * scale + 0.5;
} else {
uv.x = (uv.x - 0.5) / scale + 0.5;
}
}
uv = (uv - 0.5) / zoom + 0.5;
@@ -285,15 +309,32 @@ export type HalftoneSnapshotFn = (
},
) => Promise<Blob | null>;
export type HalftoneImageInteractionSettings = {
hoverFadeIn: number;
hoverFadeOut: number;
pointerFollow: number;
pointerVelocityDamping: number;
};
const DEFAULT_IMAGE_INTERACTION_SETTINGS: HalftoneImageInteractionSettings = {
hoverFadeIn: IMAGE_HOVER_FADE_IN,
hoverFadeOut: IMAGE_HOVER_FADE_OUT,
pointerFollow: IMAGE_POINTER_FOLLOW,
pointerVelocityDamping: IMAGE_POINTER_VELOCITY_DAMPING,
};
type HalftoneCanvasProps = {
geometry: THREE.BufferGeometry | null;
initialPose?: Partial<HalftoneExportPose>;
imageElement: HTMLImageElement | null;
imageFit?: HalftoneImageFit;
imageInteraction?: Partial<HalftoneImageInteractionSettings>;
onFirstInteraction: () => void;
onPoseChange: (pose: HalftoneExportPose) => void;
previewDistance: number;
settings: HalftoneStudioSettings;
snapshotRef?: MutableRefObject<HalftoneSnapshotFn | null>;
virtualRenderHeight?: number;
};
type SceneResources = {
@@ -325,33 +366,6 @@ type SceneResources = {
transmissionTarget: THREE.WebGLRenderTarget;
};
type InteractionState = {
activePointerId: number | null;
autoElapsed: number;
dragging: boolean;
hoverStrength: number;
mouseX: number;
mouseY: number;
pointerInside: boolean;
pointerVelocityX: number;
pointerVelocityY: number;
pointerX: number;
pointerY: number;
rotateElapsed: number;
rotationX: number;
rotationVelocityX: number;
rotationY: number;
rotationVelocityY: number;
rotationZ: number;
rotationVelocityZ: number;
smoothedMouseX: number;
smoothedMouseY: number;
targetRotationX: number;
targetRotationY: number;
velocityX: number;
velocityY: number;
};
type PixelBounds = {
maxX: number;
maxY: number;
@@ -367,6 +381,15 @@ function createRenderTarget(width: number, height: number) {
});
}
function resolveImageInteractionSettings(
settings?: Partial<HalftoneImageInteractionSettings>,
): HalftoneImageInteractionSettings {
return {
...DEFAULT_IMAGE_INTERACTION_SETTINGS,
...settings,
};
}
function syncImageElementTexture(
resources: SceneResources,
imageElement: HTMLImageElement | null,
@@ -399,37 +422,6 @@ function syncImageElementTexture(
);
}
function createInteractionState(
initialPose?: Partial<HalftoneExportPose>,
): InteractionState {
return {
activePointerId: null,
autoElapsed: initialPose?.autoElapsed ?? 0,
dragging: false,
hoverStrength: 0,
mouseX: 0.5,
mouseY: 0.5,
pointerInside: false,
pointerVelocityX: 0,
pointerVelocityY: 0,
pointerX: 0,
pointerY: 0,
rotateElapsed: initialPose?.rotateElapsed ?? 0,
rotationX: initialPose?.rotationX ?? 0,
rotationVelocityX: 0,
rotationY: initialPose?.rotationY ?? 0,
rotationVelocityY: 0,
rotationZ: initialPose?.rotationZ ?? 0,
rotationVelocityZ: 0,
smoothedMouseX: 0.5,
smoothedMouseY: 0.5,
targetRotationX: initialPose?.targetRotationX ?? 0,
targetRotationY: initialPose?.targetRotationY ?? 0,
velocityX: 0,
velocityY: 0,
};
}
function getAlphaCropBounds(
pixels: Uint8Array,
width: number,
@@ -556,83 +548,43 @@ function syncResources(
updateHalftone(resources, settings);
}
function applySpringStep(
current: number,
target: number,
velocity: number,
strength: number,
damping: number,
) {
const nextVelocity = (velocity + (target - current) * strength) * damping;
const nextValue = current + nextVelocity;
return {
value: nextValue,
velocity: nextVelocity,
};
}
function resetInteractionState(
interactionState: InteractionState,
animation: HalftoneStudioSettings['animation'],
) {
interactionState.activePointerId = null;
interactionState.dragging = false;
interactionState.hoverStrength = 0;
interactionState.mouseX = 0.5;
interactionState.mouseY = 0.5;
interactionState.pointerInside = false;
interactionState.pointerVelocityX = 0;
interactionState.pointerVelocityY = 0;
interactionState.smoothedMouseX = 0.5;
interactionState.smoothedMouseY = 0.5;
interactionState.targetRotationX = 0;
interactionState.targetRotationY = 0;
interactionState.velocityX = 0;
interactionState.velocityY = 0;
interactionState.rotationVelocityX = 0;
interactionState.rotationVelocityY = 0;
interactionState.rotationVelocityZ = 0;
if (animation.autoRotateEnabled) {
interactionState.autoElapsed = 0;
}
}
export function HalftoneCanvas({
geometry,
initialPose,
imageElement,
imageFit = 'contain',
imageInteraction,
onFirstInteraction,
onPoseChange,
previewDistance,
settings,
snapshotRef,
virtualRenderHeight = VIRTUAL_RENDER_HEIGHT,
}: HalftoneCanvasProps) {
const mountReference = useRef<HTMLDivElement>(null);
const resourcesReference = useRef<SceneResources | null>(null);
const settingsReference = useRef(settings);
const interactionReference = useRef<InteractionState>(
createInteractionState(initialPose),
const interactionReference = useRef<HalftoneInteractionState>(
createHalftoneInteractionState(initialPose),
);
const animationReference = useRef(settings.animation);
const didInteractReference = useRef(false);
const initialPoseReference = useRef(initialPose);
const poseChangeReference = useRef(onPoseChange);
const poseChangeReference = useLatestRef(onPoseChange);
const previewDistanceReference = useRef(previewDistance);
const geometryReference = useRef(geometry);
const imageElementReference = useRef(imageElement);
const snapshotReference = useRef(snapshotRef);
const imageFitReference = useRef(imageFit);
const imageInteractionReference = useRef(
resolveImageInteractionSettings(imageInteraction),
);
const snapshotReference = useLatestRef(snapshotRef);
useEffect(() => {
initialPoseReference.current = initialPose;
interactionReference.current = createInteractionState(initialPose);
interactionReference.current = createHalftoneInteractionState(initialPose);
}, [initialPose]);
useEffect(() => {
poseChangeReference.current = onPoseChange;
}, [onPoseChange]);
useEffect(() => {
previewDistanceReference.current = previewDistance;
@@ -666,7 +618,7 @@ export function HalftoneCanvas({
prev.hoverLightEnabled !== next.hoverLightEnabled ||
prev.dragFlowEnabled !== next.dragFlowEnabled
) {
resetInteractionState(interactionReference.current, next);
resetHalftoneInteractionState(interactionReference.current, next);
}
if (
@@ -696,10 +648,6 @@ export function HalftoneCanvas({
resources.mesh.geometry = geometry;
}, [geometry]);
useEffect(() => {
snapshotReference.current = snapshotRef;
}, [snapshotRef]);
useEffect(() => {
imageElementReference.current = imageElement;
@@ -712,6 +660,24 @@ export function HalftoneCanvas({
syncImageElementTexture(resources, imageElement);
}, [imageElement]);
useEffect(() => {
imageFitReference.current = imageFit;
const resources = resourcesReference.current;
if (!resources) {
return;
}
resources.imageMaterial.uniforms.imageFit.value =
imageFit === 'cover' ? 1 : 0;
}, [imageFit]);
useEffect(() => {
imageInteractionReference.current =
resolveImageInteractionSettings(imageInteraction);
}, [imageInteraction]);
useEffect(() => {
const container = mountReference.current;
const initialSettings = settingsReference.current;
@@ -722,15 +688,12 @@ export function HalftoneCanvas({
return;
}
let animationFrameId = 0;
let cancelled = false;
let isVisible = typeof document === 'undefined' ? true : !document.hidden;
let isIntersecting = true;
const shouldRender = () => isVisible && isIntersecting;
let renderLoop: VisualRenderLoop | null = null;
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
const getVirtualHeight = () => Math.max(virtualRenderHeight, getHeight());
const getVirtualWidth = () =>
Math.max(
Math.round(
@@ -745,7 +708,30 @@ export function HalftoneCanvas({
const getRenderWidth = () =>
Math.max(Math.round(getVirtualWidth() * getRenderScale()), 1);
const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true });
const webGlPolicy = evaluateWebGlPolicy();
if (!webGlPolicy.allowed) {
return;
}
let renderer: THREE.WebGLRenderer;
try {
renderer = createSiteWebGlRenderer({
antialias: false,
alpha: true,
onContextLost: () => {
renderLoop?.stop();
},
});
} catch (error) {
if (process.env.NODE_ENV !== 'production') {
console.error('Halftone renderer failed:', error);
}
return;
}
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
@@ -760,16 +746,22 @@ export function HalftoneCanvas({
container.appendChild(canvas);
let cleanup = () => {
renderer.dispose();
resourcesReference.current = null;
if (activeSnapshotRef) {
activeSnapshotRef.current = null;
}
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
runCleanupTasks([
() => renderer.dispose(),
() => {
resourcesReference.current = null;
},
() => {
if (activeSnapshotRef) {
activeSnapshotRef.current = null;
}
},
() => {
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
},
]);
};
void (async () => {
@@ -943,6 +935,7 @@ export function HalftoneCanvas({
},
zoom: { value: getImagePreviewZoom(initialPreviewDistance) },
contrast: { value: initialSettings.halftone.imageContrast },
imageFit: { value: imageFitReference.current === 'cover' ? 1 : 0 },
},
vertexShader: passThroughVertexShader,
fragmentShader: imagePassthroughFragmentShader,
@@ -1015,6 +1008,7 @@ export function HalftoneCanvas({
return getImageFootprintScale({
imageHeight: imageSize.y,
imageFit: imageFitReference.current,
imageWidth: imageSize.x,
previewDistance: activePreviewDistance,
viewportHeight,
@@ -1318,8 +1312,7 @@ export function HalftoneCanvas({
);
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
const stopObservingSize = observeElementSize(container, syncSize);
const updatePointerPosition = (
event: PointerEvent,
@@ -1568,9 +1561,6 @@ export function HalftoneCanvas({
return;
}
animationFrameId = shouldRender()
? window.requestAnimationFrame(renderFrame)
: 0;
clock.update(timestamp);
const interaction = interactionReference.current;
@@ -1601,13 +1591,14 @@ export function HalftoneCanvas({
halftoneMaterial.uniforms.cropToBounds.value = isImageMode ? 1 : 0;
if (isImageMode) {
const imageInteractionSettings = imageInteractionReference.current;
const hoverEasing =
1 -
Math.exp(
-delta *
(interaction.pointerInside
? IMAGE_HOVER_FADE_IN
: IMAGE_HOVER_FADE_OUT),
? imageInteractionSettings.hoverFadeIn
: imageInteractionSettings.hoverFadeOut),
);
interaction.hoverStrength +=
((interaction.pointerInside ? 1 : 0) - interaction.hoverStrength) *
@@ -1615,12 +1606,14 @@ export function HalftoneCanvas({
interaction.smoothedMouseX +=
(interaction.mouseX - interaction.smoothedMouseX) *
IMAGE_POINTER_FOLLOW;
imageInteractionSettings.pointerFollow;
interaction.smoothedMouseY +=
(interaction.mouseY - interaction.smoothedMouseY) *
IMAGE_POINTER_FOLLOW;
interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING;
interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING;
imageInteractionSettings.pointerFollow;
interaction.pointerVelocityX *=
imageInteractionSettings.pointerVelocityDamping;
interaction.pointerVelocityY *=
imageInteractionSettings.pointerVelocityDamping;
halftoneMaterial.uniforms.interactionUv.value.set(
interaction.smoothedMouseX,
@@ -1842,27 +1835,27 @@ export function HalftoneCanvas({
}
if (activeSettings.animation.springReturnEnabled) {
const springX = applySpringStep(
interaction.rotationX,
targetX,
interaction.rotationVelocityX,
activeSettings.animation.springStrength,
activeSettings.animation.springDamping,
);
const springY = applySpringStep(
interaction.rotationY,
targetY,
interaction.rotationVelocityY,
activeSettings.animation.springStrength,
activeSettings.animation.springDamping,
);
const springZ = applySpringStep(
interaction.rotationZ,
baseRotationZ,
interaction.rotationVelocityZ,
activeSettings.animation.springStrength,
activeSettings.animation.springDamping,
);
const springX = applySpringStep({
current: interaction.rotationX,
damping: activeSettings.animation.springDamping,
strength: activeSettings.animation.springStrength,
target: targetX,
velocity: interaction.rotationVelocityX,
});
const springY = applySpringStep({
current: interaction.rotationY,
damping: activeSettings.animation.springDamping,
strength: activeSettings.animation.springStrength,
target: targetY,
velocity: interaction.rotationVelocityY,
});
const springZ = applySpringStep({
current: interaction.rotationZ,
damping: activeSettings.animation.springDamping,
strength: activeSettings.animation.springStrength,
target: baseRotationZ,
velocity: interaction.rotationVelocityZ,
});
interaction.rotationX = springX.value;
interaction.rotationY = springY.value;
@@ -1992,82 +1985,61 @@ export function HalftoneCanvas({
renderer.render(postScene, orthographicCamera);
};
const resumeIfNeeded = () => {
if (!cancelled && shouldRender() && animationFrameId === 0) {
animationFrameId = window.requestAnimationFrame(renderFrame);
}
};
const handleVisibilityChange = () => {
isVisible = !document.hidden;
if (!shouldRender() && animationFrameId !== 0) {
window.cancelAnimationFrame(animationFrameId);
animationFrameId = 0;
} else {
resumeIfNeeded();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
const intersectionObserver = new IntersectionObserver(
(entries) => {
isIntersecting = entries.some((entry) => entry.isIntersecting);
if (!shouldRender() && animationFrameId !== 0) {
window.cancelAnimationFrame(animationFrameId);
animationFrameId = 0;
} else {
resumeIfNeeded();
}
},
{ rootMargin: '100px' },
);
intersectionObserver.observe(container);
renderFrame();
renderLoop = createVisualRenderLoop({
renderFrame,
shouldRender: () => !cancelled,
target: container,
targetVisibilityOptions: { rootMargin: '100px' },
});
renderLoop.start();
cleanup = () => {
resizeObserver.disconnect();
intersectionObserver.disconnect();
document.removeEventListener(
'visibilitychange',
handleVisibilityChange,
);
canvas.removeEventListener('pointermove', handlePointerMove);
canvas.removeEventListener('pointerleave', handlePointerLeave);
canvas.removeEventListener('pointerup', handlePointerUp);
canvas.removeEventListener('pointercancel', handlePointerCancel);
window.removeEventListener('blur', handleWindowBlur);
canvas.removeEventListener('pointerdown', handlePointerDown);
window.cancelAnimationFrame(animationFrameId);
clock.dispose();
runCleanupTasks([
() => stopObservingSize(),
() => renderLoop?.dispose(),
() => canvas.removeEventListener('pointermove', handlePointerMove),
() => canvas.removeEventListener('pointerleave', handlePointerLeave),
() => canvas.removeEventListener('pointerup', handlePointerUp),
() =>
canvas.removeEventListener('pointercancel', handlePointerCancel),
() => window.removeEventListener('blur', handleWindowBlur),
() => canvas.removeEventListener('pointerdown', handlePointerDown),
() => clock.dispose(),
blurHorizontalMaterial.dispose();
blurVerticalMaterial.dispose();
halftoneMaterial.dispose();
imageMaterial.dispose();
() => blurHorizontalMaterial.dispose(),
() => blurVerticalMaterial.dispose(),
() => halftoneMaterial.dispose(),
() => imageMaterial.dispose(),
if (resources.imageTexture) {
resources.imageTexture.dispose();
}
() => {
if (resources.imageTexture) {
resources.imageTexture.dispose();
}
},
fullScreenGeometry.dispose();
material.dispose();
sceneTarget.dispose();
transmissionBacksideTarget.dispose();
transmissionTarget.dispose();
blurTargetA.dispose();
blurTargetB.dispose();
disposeHalftoneMaterialAssets(materialAssets);
renderer.dispose();
resourcesReference.current = null;
if (activeSnapshotRef) {
activeSnapshotRef.current = null;
}
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
() => fullScreenGeometry.dispose(),
() => material.dispose(),
() => sceneTarget.dispose(),
() => transmissionBacksideTarget.dispose(),
() => transmissionTarget.dispose(),
() => blurTargetA.dispose(),
() => blurTargetB.dispose(),
() => disposeHalftoneMaterialAssets(materialAssets),
() => renderer.dispose(),
() => {
resourcesReference.current = null;
},
() => {
if (activeSnapshotRef) {
activeSnapshotRef.current = null;
}
},
() => {
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
},
]);
};
})();
@@ -2075,7 +2047,12 @@ export function HalftoneCanvas({
cancelled = true;
cleanup();
};
}, [onFirstInteraction]);
}, [
onFirstInteraction,
poseChangeReference,
snapshotReference,
virtualRenderHeight,
]);
return (
<CanvasMount
@@ -0,0 +1,160 @@
'use client';
import { useLatestRef } from '@/lib/react';
import {
type MutableRefObject,
type RefObject,
useEffect,
useRef,
useState,
} from 'react';
import * as THREE from 'three';
import {
HalftoneCanvas,
type HalftoneImageInteractionSettings,
type HalftoneSnapshotFn,
} from './halftone-canvas';
import type { HalftoneExportPose, HalftoneStudioSettings } from './state';
import type { HalftoneImageFit } from './footprint';
type HalftoneImageCanvasProps = {
crossOrigin?: HTMLImageElement['crossOrigin'];
imageFit?: HalftoneImageFit;
imageInteraction?: Partial<HalftoneImageInteractionSettings>;
imageUrl: string;
initialPose?: Partial<HalftoneExportPose>;
onFirstInteraction?: () => void;
onImageLoadError?: (error: Error) => void;
onPoseChange?: (pose: HalftoneExportPose) => void;
previewDistance: number;
settings: HalftoneStudioSettings;
snapshotRef?: MutableRefObject<HalftoneSnapshotFn | null>;
virtualRenderHeight?: number;
};
const noopFirstInteraction = () => {};
const noopPoseChange = (_pose: HalftoneExportPose) => {};
function createImageLoadError(imageUrl: string) {
return new Error(`Halftone image failed to load: ${imageUrl}`);
}
function usePlaneGeometry() {
const geometryReference = useRef<THREE.PlaneGeometry | null>(null);
if (geometryReference.current === null) {
geometryReference.current = new THREE.PlaneGeometry(1, 1);
}
useEffect(() => {
const geometry = geometryReference.current;
return () => {
geometry?.dispose();
geometryReference.current = null;
};
}, []);
return geometryReference.current;
}
function useImageElement({
crossOrigin,
imageUrl,
onImageLoadErrorReference,
}: {
crossOrigin: HTMLImageElement['crossOrigin'] | undefined;
imageUrl: string;
onImageLoadErrorReference: RefObject<((error: Error) => void) | undefined>;
}) {
const [imageElement, setImageElement] = useState<HTMLImageElement | null>(
null,
);
useEffect(() => {
let cancelled = false;
const image = new Image();
setImageElement(null);
if (typeof crossOrigin !== 'undefined') {
image.crossOrigin = crossOrigin;
}
image.decoding = 'async';
image.onload = () => {
if (!cancelled) {
setImageElement(image);
}
};
image.onerror = () => {
if (!cancelled) {
const handler = onImageLoadErrorReference.current;
const error = createImageLoadError(imageUrl);
if (handler) {
handler(error);
return;
}
if (process.env.NODE_ENV !== 'production') {
console.error(error);
}
}
};
image.src = imageUrl;
return () => {
cancelled = true;
image.onload = null;
image.onerror = null;
image.src = '';
};
}, [crossOrigin, imageUrl, onImageLoadErrorReference]);
return imageElement;
}
export function HalftoneImageCanvas({
crossOrigin,
imageFit,
imageInteraction,
imageUrl,
initialPose,
onFirstInteraction = noopFirstInteraction,
onImageLoadError,
onPoseChange = noopPoseChange,
previewDistance,
settings,
snapshotRef,
virtualRenderHeight,
}: HalftoneImageCanvasProps) {
const imageLoadErrorReference = useLatestRef(onImageLoadError);
const imageElement = useImageElement({
crossOrigin,
imageUrl,
onImageLoadErrorReference: imageLoadErrorReference,
});
const geometry = usePlaneGeometry();
if (!imageElement || !geometry) {
return null;
}
return (
<HalftoneCanvas
geometry={geometry}
imageElement={imageElement}
imageFit={imageFit}
imageInteraction={imageInteraction}
initialPose={initialPose}
onFirstInteraction={onFirstInteraction}
onPoseChange={onPoseChange}
previewDistance={previewDistance}
settings={settings}
snapshotRef={snapshotRef}
virtualRenderHeight={virtualRenderHeight}
/>
);
}
@@ -0,0 +1,154 @@
'use client';
import { useLatestRef } from '@/lib/react';
import {
type MutableRefObject,
type RefObject,
useEffect,
useState,
} from 'react';
import type * as THREE from 'three';
import {
createFallbackGeometry,
loadImportedGeometryFromUrl,
type ImportedGeometryNormalizationOptions,
} from './geometry-registry';
import { HalftoneCanvas, type HalftoneSnapshotFn } from './halftone-canvas';
import type {
HalftoneExportPose,
HalftoneModelLoader,
HalftoneStudioSettings,
} from './state';
type HalftoneModelCanvasProps = {
geometryOptions?: ImportedGeometryNormalizationOptions;
initialPose?: Partial<HalftoneExportPose>;
loader?: HalftoneModelLoader;
modelLabel?: string;
modelUrl: string;
onFirstInteraction?: () => void;
onGeometryLoadError?: (error: Error) => void;
onPoseChange?: (pose: HalftoneExportPose) => void;
previewDistance: number;
settings: HalftoneStudioSettings;
snapshotRef?: MutableRefObject<HalftoneSnapshotFn | null>;
virtualRenderHeight?: number;
};
const noopFirstInteraction = () => {};
const noopPoseChange = (_pose: HalftoneExportPose) => {};
function toGeometryLoadError(error: unknown, modelUrl: string) {
if (error instanceof Error) {
return error;
}
return new Error(`Halftone model failed to load: ${modelUrl}`);
}
function useModelGeometry({
geometryOptions,
loader,
modelLabel,
modelUrl,
onGeometryLoadErrorReference,
}: {
geometryOptions: ImportedGeometryNormalizationOptions | undefined;
loader: HalftoneModelLoader;
modelLabel: string;
modelUrl: string;
onGeometryLoadErrorReference: RefObject<((error: Error) => void) | undefined>;
}) {
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
useEffect(() => {
let cancelled = false;
let loadedGeometry: THREE.BufferGeometry | null = null;
setGeometry(null);
void loadImportedGeometryFromUrl(
loader,
modelUrl,
modelLabel,
geometryOptions,
)
.catch((error: unknown) => {
const normalizedError = toGeometryLoadError(error, modelUrl);
const handler = onGeometryLoadErrorReference.current;
if (handler) {
handler(normalizedError);
} else if (process.env.NODE_ENV !== 'production') {
console.error(normalizedError);
}
return createFallbackGeometry();
})
.then((nextGeometry) => {
if (cancelled) {
nextGeometry.dispose();
return;
}
loadedGeometry = nextGeometry;
setGeometry(nextGeometry);
});
return () => {
cancelled = true;
loadedGeometry?.dispose();
};
}, [
geometryOptions,
loader,
modelLabel,
modelUrl,
onGeometryLoadErrorReference,
]);
return geometry;
}
export function HalftoneModelCanvas({
geometryOptions,
initialPose,
loader = 'glb',
modelLabel,
modelUrl,
onFirstInteraction = noopFirstInteraction,
onGeometryLoadError,
onPoseChange = noopPoseChange,
previewDistance,
settings,
snapshotRef,
virtualRenderHeight,
}: HalftoneModelCanvasProps) {
const geometryLoadErrorReference = useLatestRef(onGeometryLoadError);
const geometry = useModelGeometry({
geometryOptions,
loader,
modelLabel: modelLabel ?? modelUrl.split('/').pop() ?? 'halftone model',
modelUrl,
onGeometryLoadErrorReference: geometryLoadErrorReference,
});
if (!geometry) {
return null;
}
return (
<HalftoneCanvas
geometry={geometry}
imageElement={null}
initialPose={initialPose}
onFirstInteraction={onFirstInteraction}
onPoseChange={onPoseChange}
previewDistance={previewDistance}
settings={settings}
snapshotRef={snapshotRef}
virtualRenderHeight={virtualRenderHeight}
/>
);
}
@@ -1,6 +1,16 @@
export { HalftoneCanvas } from './halftone-canvas';
export {
HalftoneCanvas,
type HalftoneImageInteractionSettings,
type HalftoneSnapshotFn,
} from './halftone-canvas';
export { HalftoneImageCanvas } from './halftone-image-canvas';
export { HalftoneModelCanvas } from './halftone-model-canvas';
export { loadImportedGeometryFromUrl } from './geometry-registry';
export {
createFallbackGeometry,
loadImportedGeometryFromUrl,
type ImportedGeometryNormalizationOptions,
} from './geometry-registry';
export {
DEFAULT_GLASS_ANIMATION_SETTINGS,
@@ -27,6 +37,7 @@ export type {
HalftoneMaterialSettings,
HalftoneMaterialSurface,
HalftoneStudioSettings,
HalftoneStudioSettingsOverrides,
HalftoneToneTarget,
} from './state';
@@ -35,6 +46,7 @@ export {
getImageFootprintScale,
getImagePreviewZoom,
getMeshFootprintScale,
type HalftoneImageFit,
HALFTONE_FOOTPRINT_RUNTIME_SOURCE,
REFERENCE_PREVIEW_DISTANCE,
VIRTUAL_RENDER_HEIGHT,
@@ -0,0 +1,108 @@
import type {
HalftoneExportPose,
HalftoneStudioSettings,
} from '@/lib/halftone/state';
export type HalftoneInteractionState = {
activePointerId: number | null;
autoElapsed: number;
dragging: boolean;
hoverStrength: number;
mouseX: number;
mouseY: number;
pointerInside: boolean;
pointerVelocityX: number;
pointerVelocityY: number;
pointerX: number;
pointerY: number;
rotateElapsed: number;
rotationVelocityX: number;
rotationVelocityY: number;
rotationVelocityZ: number;
rotationX: number;
rotationY: number;
rotationZ: number;
smoothedMouseX: number;
smoothedMouseY: number;
targetRotationX: number;
targetRotationY: number;
velocityX: number;
velocityY: number;
};
export const createHalftoneInteractionState = (
initialPose?: Partial<HalftoneExportPose>,
): HalftoneInteractionState => ({
activePointerId: null,
autoElapsed: initialPose?.autoElapsed ?? 0,
dragging: false,
hoverStrength: 0,
mouseX: 0.5,
mouseY: 0.5,
pointerInside: false,
pointerVelocityX: 0,
pointerVelocityY: 0,
pointerX: 0,
pointerY: 0,
rotateElapsed: initialPose?.rotateElapsed ?? 0,
rotationVelocityX: 0,
rotationVelocityY: 0,
rotationVelocityZ: 0,
rotationX: initialPose?.rotationX ?? 0,
rotationY: initialPose?.rotationY ?? 0,
rotationZ: initialPose?.rotationZ ?? 0,
smoothedMouseX: 0.5,
smoothedMouseY: 0.5,
targetRotationX: initialPose?.targetRotationX ?? 0,
targetRotationY: initialPose?.targetRotationY ?? 0,
velocityX: 0,
velocityY: 0,
});
export const applySpringStep = ({
current,
damping,
strength,
target,
velocity,
}: {
current: number;
damping: number;
strength: number;
target: number;
velocity: number;
}) => {
const nextVelocity = (velocity + (target - current) * strength) * damping;
return {
value: current + nextVelocity,
velocity: nextVelocity,
};
};
export const resetHalftoneInteractionState = (
interactionState: HalftoneInteractionState,
animation: HalftoneStudioSettings['animation'],
) => {
interactionState.activePointerId = null;
interactionState.dragging = false;
interactionState.hoverStrength = 0;
interactionState.mouseX = 0.5;
interactionState.mouseY = 0.5;
interactionState.pointerInside = false;
interactionState.pointerVelocityX = 0;
interactionState.pointerVelocityY = 0;
interactionState.smoothedMouseX = 0.5;
interactionState.smoothedMouseY = 0.5;
interactionState.targetRotationX = 0;
interactionState.targetRotationY = 0;
interactionState.velocityX = 0;
interactionState.velocityY = 0;
interactionState.rotationVelocityX = 0;
interactionState.rotationVelocityY = 0;
interactionState.rotationVelocityZ = 0;
if (animation.autoRotateEnabled) {
interactionState.autoElapsed = 0;
}
};
@@ -111,6 +111,19 @@ export interface HalftoneStudioSettings {
animation: HalftoneAnimationSettings;
}
export type HalftoneStudioSettingsOverrides = Partial<
Omit<
HalftoneStudioSettings,
'lighting' | 'material' | 'halftone' | 'background' | 'animation'
>
> & {
lighting?: Partial<HalftoneLightingSettings>;
material?: Partial<HalftoneMaterialSettings>;
halftone?: Partial<HalftoneEffectSettings>;
background?: Partial<HalftoneBackgroundSettings>;
animation?: Partial<HalftoneAnimationSettings>;
};
export interface HalftoneGeometrySpec {
key: string;
label: string;
@@ -533,7 +546,7 @@ function materialMatches(
}
export function normalizeHalftoneStudioSettings(
settings?: Partial<HalftoneStudioSettings>,
settings?: HalftoneStudioSettingsOverrides,
): HalftoneStudioSettings {
const sourceMode =
settings?.sourceMode ?? DEFAULT_HALFTONE_SETTINGS.sourceMode;
@@ -1,46 +1,46 @@
import { detectLocale } from '../detect-locale';
describe('detectLocale', () => {
it('returns the cookie value when it is a supported locale', () => {
it('returns the cookie value when it is a published website locale', () => {
expect(
detectLocale({
cookieValue: 'fr-FR',
cookieValue: 'en',
acceptLanguageHeader: 'de-DE,de;q=0.9',
}),
).toBe('fr-FR');
).toBe('en');
});
it('ignores an unsupported cookie value and falls back to Accept-Language', () => {
it('ignores unsupported cookie and Accept-Language values', () => {
expect(
detectLocale({
cookieValue: 'xx-YY',
acceptLanguageHeader: 'de-DE,de;q=0.9',
}),
).toBe('de-DE');
).toBe('en');
});
it('matches the highest-quality Accept-Language entry first', () => {
it('matches the highest-quality published Accept-Language entry first', () => {
expect(
detectLocale({
acceptLanguageHeader: 'en;q=0.5,fr-FR;q=0.9,de;q=0.7',
}),
).toBe('fr-FR');
).toBe('en');
});
it('falls back from a regional tag to the language family', () => {
it('falls back to the source locale when a regional language is not published', () => {
expect(
detectLocale({
acceptLanguageHeader: 'fr-CA',
}),
).toBe('fr-FR');
).toBe('en');
});
it('falls back from a bare language tag to the first matching regional locale', () => {
it('falls back to the source locale when a bare language is not published', () => {
expect(
detectLocale({
acceptLanguageHeader: 'pt;q=1.0,en;q=0.5',
}),
).toBe('pt-BR');
).toBe('en');
});
it('returns the source locale when no input is provided', () => {
@@ -60,7 +60,7 @@ describe('detectLocale', () => {
detectLocale({
acceptLanguageHeader: 'fr-FR;q=not-a-number,de-DE;q=0.5',
}),
).toBe('de-DE');
).toBe('en');
});
it('parses quality values when parameters carry leading whitespace around the semicolon', () => {
@@ -68,6 +68,6 @@ describe('detectLocale', () => {
detectLocale({
acceptLanguageHeader: 'en;q=0.1, fr-FR ; q=0.9, de-DE ; q=0.5',
}),
).toBe('fr-FR');
).toBe('en');
});
});
@@ -1,8 +1,9 @@
import { localizeHref, stripLocale } from '../localize-href';
describe('localizeHref', () => {
it('prefixes a non-default locale onto an internal absolute path', () => {
expect(localizeHref('fr-FR', '/pricing')).toBe('/fr-FR/pricing');
it('does not emit locale prefixes for locales the website does not publish yet', () => {
expect(localizeHref('fr-FR', '/pricing')).toBe('/pricing');
expect(localizeHref('de-DE', '/pricing')).toBe('/pricing');
});
it('returns paths unprefixed for the default locale (English at root)', () => {
@@ -10,24 +11,22 @@ describe('localizeHref', () => {
expect(localizeHref('en', '/')).toBe('/');
});
it('prefixes a non-default locale onto the root path', () => {
expect(localizeHref('fr-FR', '/')).toBe('/fr-FR/');
it('keeps the root path unprefixed for unpublished locales', () => {
expect(localizeHref('fr-FR', '/')).toBe('/');
});
it('preserves query strings and hash fragments', () => {
expect(localizeHref('de-DE', '/customers?ref=hero#top')).toBe(
'/de-DE/customers?ref=hero#top',
'/customers?ref=hero#top',
);
expect(localizeHref('en', '/customers?ref=hero#top')).toBe(
'/customers?ref=hero#top',
);
});
it('does not double-prefix paths that already start with a non-default locale', () => {
expect(localizeHref('fr-FR', '/de-DE/why-twenty')).toBe(
'/de-DE/why-twenty',
);
expect(localizeHref('fr-FR', '/fr-FR/pricing')).toBe('/fr-FR/pricing');
it('strips legacy locale prefixes when targeting an unpublished locale', () => {
expect(localizeHref('fr-FR', '/de-DE/why-twenty')).toBe('/why-twenty');
expect(localizeHref('fr-FR', '/fr-FR/pricing')).toBe('/pricing');
});
it('strips a redundant /en prefix when targeting the default locale', () => {
@@ -35,9 +34,9 @@ describe('localizeHref', () => {
expect(localizeHref('en', '/en')).toBe('/');
});
it('rewrites an /en-prefixed path onto the active non-default locale', () => {
expect(localizeHref('fr-FR', '/en/why-twenty')).toBe('/fr-FR/why-twenty');
expect(localizeHref('fr-FR', '/en')).toBe('/fr-FR/');
it('strips an /en-prefixed path when targeting an unpublished locale', () => {
expect(localizeHref('fr-FR', '/en/why-twenty')).toBe('/why-twenty');
expect(localizeHref('fr-FR', '/en')).toBe('/');
});
it('passes external https URLs through unchanged', () => {
@@ -67,14 +66,14 @@ describe('localizeHref', () => {
it('handles a locale segment immediately followed by a query string', () => {
expect(localizeHref('en', '/en?ref=hero')).toBe('/?ref=hero');
expect(localizeHref('fr-FR', '/en?ref=hero')).toBe('/fr-FR/?ref=hero');
expect(localizeHref('fr-FR', '/de-DE?ref=hero')).toBe('/de-DE?ref=hero');
expect(localizeHref('fr-FR', '/en?ref=hero')).toBe('/?ref=hero');
expect(localizeHref('fr-FR', '/de-DE?ref=hero')).toBe('/?ref=hero');
});
it('handles a locale segment immediately followed by a hash fragment', () => {
expect(localizeHref('en', '/en#anchor')).toBe('/#anchor');
expect(localizeHref('fr-FR', '/en#anchor')).toBe('/fr-FR/#anchor');
expect(localizeHref('fr-FR', '/de-DE#anchor')).toBe('/de-DE#anchor');
expect(localizeHref('fr-FR', '/en#anchor')).toBe('/#anchor');
expect(localizeHref('fr-FR', '/de-DE#anchor')).toBe('/#anchor');
});
});
@@ -1,14 +1,30 @@
import { APP_LOCALES, type AppLocale } from 'twenty-shared/translations';
import {
APP_LOCALES,
SOURCE_LOCALE,
type AppLocale,
} from 'twenty-shared/translations';
const APP_LOCALE_VALUES: readonly AppLocale[] = Object.values(APP_LOCALES);
export const isPublicAppLocale = (locale: AppLocale): boolean =>
const isKnownPublicLocale = (locale: AppLocale): boolean =>
!locale.startsWith('pseudo-');
export const KNOWN_PUBLIC_APP_LOCALE_LIST: readonly AppLocale[] =
APP_LOCALE_VALUES.filter(isKnownPublicLocale);
export const WEBSITE_LOCALE_LIST: readonly AppLocale[] = [SOURCE_LOCALE];
const WEBSITE_LOCALE_SET: ReadonlySet<AppLocale> = new Set(WEBSITE_LOCALE_LIST);
export const isPublicAppLocale = (locale: AppLocale): boolean =>
WEBSITE_LOCALE_SET.has(locale);
export const APP_LOCALE_LIST: readonly AppLocale[] = APP_LOCALE_VALUES;
export const PUBLIC_APP_LOCALE_LIST: readonly AppLocale[] =
APP_LOCALE_VALUES.filter(isPublicAppLocale);
export const PUBLIC_APP_LOCALE_LIST: readonly AppLocale[] = WEBSITE_LOCALE_LIST;
export const KNOWN_PUBLIC_APP_LOCALE_BY_RAW: ReadonlyMap<string, AppLocale> =
new Map(KNOWN_PUBLIC_APP_LOCALE_LIST.map((locale) => [locale, locale]));
export const APP_LOCALE_BY_RAW: ReadonlyMap<string, AppLocale> = new Map(
PUBLIC_APP_LOCALE_LIST.map((locale) => [locale, locale]),
@@ -2,7 +2,10 @@ export {
APP_LOCALE_BY_LANGUAGE,
APP_LOCALE_BY_RAW,
APP_LOCALE_LIST,
KNOWN_PUBLIC_APP_LOCALE_BY_RAW,
KNOWN_PUBLIC_APP_LOCALE_LIST,
PUBLIC_APP_LOCALE_LIST,
WEBSITE_LOCALE_LIST,
isPublicAppLocale,
} from './app-locale-set';
export { createI18nInstance } from './create-i18n-instance';
@@ -1,6 +1,9 @@
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import { APP_LOCALE_BY_RAW } from './app-locale-set';
import {
KNOWN_PUBLIC_APP_LOCALE_BY_RAW,
isPublicAppLocale,
} from './app-locale-set';
const findFirstSegmentEnd = (path: string): number => {
for (let i = 1; i < path.length; i += 1) {
@@ -22,18 +25,16 @@ export const localizeHref = (locale: AppLocale, href: string): string => {
const segmentEnd = findFirstSegmentEnd(href);
const firstSegment = href.slice(1, segmentEnd);
const existingLocale = APP_LOCALE_BY_RAW.get(firstSegment);
if (existingLocale !== undefined && existingLocale !== SOURCE_LOCALE) {
return href;
}
const existingLocale = KNOWN_PUBLIC_APP_LOCALE_BY_RAW.get(firstSegment);
const unprefixed =
existingLocale === SOURCE_LOCALE
existingLocale !== undefined
? buildTailFromSegmentEnd(href, segmentEnd)
: href;
return locale === SOURCE_LOCALE ? unprefixed : `/${locale}${unprefixed}`;
return locale === SOURCE_LOCALE || !isPublicAppLocale(locale)
? unprefixed
: `/${locale}${unprefixed}`;
};
export const stripLocale = (pathname: string): string => {
@@ -41,7 +42,7 @@ export const stripLocale = (pathname: string): string => {
const segmentEnd = findFirstSegmentEnd(pathname);
const firstSegment = pathname.slice(1, segmentEnd);
if (!APP_LOCALE_BY_RAW.has(firstSegment)) return pathname;
if (!KNOWN_PUBLIC_APP_LOCALE_BY_RAW.has(firstSegment)) return pathname;
return buildTailFromSegmentEnd(pathname, segmentEnd);
};
@@ -0,0 +1,34 @@
import { runCleanupTasks } from '../run-cleanup-tasks';
describe('runCleanupTasks', () => {
it('runs cleanup tasks in declaration order', () => {
const calls: string[] = [];
runCleanupTasks(
[() => calls.push('first'), () => calls.push('second')],
jest.fn(),
);
expect(calls).toEqual(['first', 'second']);
});
it('continues cleanup after a task throws', () => {
const error = new Error('dispose failed');
const calls: string[] = [];
const onError = jest.fn();
runCleanupTasks(
[
() => calls.push('first'),
() => {
throw error;
},
() => calls.push('third'),
],
onError,
);
expect(calls).toEqual(['first', 'third']);
expect(onError).toHaveBeenCalledWith(error);
});
});
@@ -0,0 +1,73 @@
import { createTimeoutRegistry } from '../timeout-registry';
function createManualTimerScheduler() {
let nextHandle = 1;
const callbacks = new Map<number, () => void>();
return {
callbacks,
clearTimeout: jest.fn((handle: number) => {
callbacks.delete(handle);
}),
run: (handle: number) => {
callbacks.get(handle)?.();
},
setTimeout: jest.fn((callback: () => void) => {
const handle = nextHandle;
nextHandle += 1;
callbacks.set(handle, callback);
return handle;
}),
};
}
describe('createTimeoutRegistry', () => {
it('tracks and runs scheduled callbacks once', () => {
const scheduler = createManualTimerScheduler();
const registry = createTimeoutRegistry(scheduler);
const callback = jest.fn();
registry.schedule(callback, 200);
expect(registry.pendingCount()).toBe(1);
scheduler.run(1);
expect(callback).toHaveBeenCalledTimes(1);
expect(registry.pendingCount()).toBe(0);
});
it('cancels one scheduled callback without clearing unrelated callbacks', () => {
const scheduler = createManualTimerScheduler();
const registry = createTimeoutRegistry(scheduler);
const firstCallback = jest.fn();
const secondCallback = jest.fn();
const cancelFirst = registry.schedule(firstCallback, 200);
registry.schedule(secondCallback, 300);
cancelFirst();
expect(scheduler.clearTimeout).toHaveBeenCalledWith(1);
expect(registry.pendingCount()).toBe(1);
scheduler.run(1);
scheduler.run(2);
expect(firstCallback).not.toHaveBeenCalled();
expect(secondCallback).toHaveBeenCalledTimes(1);
expect(registry.pendingCount()).toBe(0);
});
it('clears every pending timeout', () => {
const scheduler = createManualTimerScheduler();
const registry = createTimeoutRegistry(scheduler);
registry.schedule(jest.fn(), 200);
registry.schedule(jest.fn(), 300);
registry.clearAll();
expect(scheduler.clearTimeout).toHaveBeenCalledWith(1);
expect(scheduler.clearTimeout).toHaveBeenCalledWith(2);
expect(registry.pendingCount()).toBe(0);
});
});
@@ -0,0 +1,20 @@
export type CleanupErrorHandler = (error: unknown) => void;
export const reportCleanupErrorInDevelopment: CleanupErrorHandler = (error) => {
if (process.env.NODE_ENV !== 'production') {
console.error('Cleanup task failed:', error);
}
};
export const runCleanupTasks = (
cleanupTasks: ReadonlyArray<() => void>,
onError: CleanupErrorHandler = reportCleanupErrorInDevelopment,
) => {
cleanupTasks.forEach((cleanupTask) => {
try {
cleanupTask();
} catch (error) {
onError(error);
}
});
};
@@ -0,0 +1,53 @@
export type TimeoutHandle = number | ReturnType<typeof setTimeout>;
export type TimeoutRegistry = {
clearAll: () => void;
pendingCount: () => number;
schedule: (callback: () => void, delayMs: number) => () => void;
};
type CreateTimeoutRegistryOptions<Handle> = {
clearTimeout?: (handle: Handle) => void;
setTimeout?: (callback: () => void, delayMs: number) => Handle;
};
export function createTimeoutRegistry<Handle = TimeoutHandle>({
clearTimeout: clearTimeoutFn = globalThis.clearTimeout as unknown as (
handle: Handle,
) => void,
setTimeout: setTimeoutFn = globalThis.setTimeout as unknown as (
callback: () => void,
delayMs: number,
) => Handle,
}: CreateTimeoutRegistryOptions<Handle> = {}): TimeoutRegistry {
const handles = new Set<Handle>();
const cancel = (handle: Handle) => {
if (!handles.delete(handle)) {
return;
}
clearTimeoutFn(handle);
};
const clearAll = () => {
Array.from(handles).forEach(cancel);
};
const schedule = (callback: () => void, delayMs: number) => {
const handle = setTimeoutFn(() => {
handles.delete(handle);
callback();
}, delayMs);
handles.add(handle);
return () => cancel(handle);
};
return {
clearAll,
pendingCount: () => handles.size,
schedule,
};
}
@@ -0,0 +1,2 @@
export { useLatestRef } from './use-latest-ref';
export { useTimeoutRegistry } from './use-timeout-registry';
@@ -0,0 +1,7 @@
import { useRef, type RefObject } from 'react';
export const useLatestRef = <Value>(value: Value): RefObject<Value> => {
const reference = useRef(value);
reference.current = value;
return reference;
};
@@ -0,0 +1,21 @@
import {
createTimeoutRegistry,
type TimeoutRegistry,
} from '@/lib/lifecycle/timeout-registry';
import { useEffect, useRef } from 'react';
export function useTimeoutRegistry(): TimeoutRegistry {
const registryReference = useRef<TimeoutRegistry | null>(null);
registryReference.current ??= createTimeoutRegistry();
useEffect(() => {
const registry = registryReference.current;
return () => {
registry?.clearAll();
};
}, []);
return registryReference.current;
}
@@ -2,6 +2,8 @@
import { useEffect } from 'react';
import { createAnimationFrameLoop } from '@/lib/animation';
type UseScheduledOnScrollOptions = {
enabled?: boolean;
fireImmediately?: boolean;
@@ -18,19 +20,14 @@ export function useScheduledOnScroll(
return;
}
let rafId: number | null = null;
const scrollTask = createAnimationFrameLoop({
onFrame: () => {
callback();
return false;
},
});
const flush = () => {
rafId = null;
callback();
};
const schedule = () => {
if (rafId !== null) {
return;
}
rafId = window.requestAnimationFrame(flush);
};
const schedule = scrollTask.start;
if (fireImmediately) {
callback();
@@ -41,9 +38,7 @@ export function useScheduledOnScroll(
return () => {
window.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
scrollTask.stop();
};
}, [callback, enabled, fireImmediately]);
}
@@ -19,6 +19,7 @@ describe('buildPageMetadata', () => {
description: 'Product page description.',
});
expect(metadata.title).toEqual({ absolute: 'Product | Twenty' });
expect(metadata.alternates).toMatchObject({ canonical: '/product' });
expect(metadata.openGraph).toMatchObject({
title: 'Product | Twenty',
@@ -35,7 +36,7 @@ describe('buildPageMetadata', () => {
});
});
it('emits a prefixed canonical for non-default locales', () => {
it('falls back to the source canonical for locales the website does not publish', () => {
const metadata = buildPageMetadata({
locale: 'fr-FR',
path: '/product',
@@ -43,10 +44,11 @@ describe('buildPageMetadata', () => {
description: 'd',
});
expect(metadata.alternates).toMatchObject({ canonical: '/fr-FR/product' });
expect(metadata.alternates).toMatchObject({ canonical: '/product' });
expect(metadata.openGraph).toMatchObject({ locale: 'en' });
});
it('emits hreflang language alternates: English unprefixed, others prefixed, x-default unprefixed', () => {
it('emits hreflang alternates only for published website locales', () => {
const metadata = buildPageMetadata({
locale: 'fr-FR',
path: '/pricing',
@@ -58,8 +60,8 @@ describe('buildPageMetadata', () => {
| Record<string, string>
| undefined;
expect(languages?.en).toBe('/pricing');
expect(languages?.['fr-FR']).toBe('/fr-FR/pricing');
expect(languages?.['zh-CN']).toBe('/zh-CN/pricing');
expect(languages?.['fr-FR']).toBeUndefined();
expect(languages?.['zh-CN']).toBeUndefined();
expect(languages?.['x-default']).toBe('/pricing');
});
@@ -85,11 +87,11 @@ describe('buildPageMetadata', () => {
description: 'd',
});
expect(metadata.alternates).toMatchObject({ canonical: '/de-DE' });
expect(metadata.alternates).toMatchObject({ canonical: '/' });
const languages = metadata.alternates?.languages as
| Record<string, string>
| undefined;
expect(languages?.['de-DE']).toBe('/de-DE');
expect(languages?.['de-DE']).toBeUndefined();
expect(languages?.en).toBe('/');
expect(languages?.['x-default']).toBe('/');
});
@@ -1,7 +1,10 @@
import type { Metadata } from 'next';
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import { PUBLIC_APP_LOCALE_LIST } from '@/lib/i18n/app-locale-set';
import {
PUBLIC_APP_LOCALE_LIST,
isPublicAppLocale,
} from '@/lib/i18n/app-locale-set';
import { getSiteUrl } from './site-url';
@@ -22,7 +25,7 @@ const normalizePath = (path: string): string =>
path.startsWith('/') ? path : `/${path}`;
const localizePath = (locale: AppLocale, normalizedPath: string): string => {
if (locale === SOURCE_LOCALE) {
if (locale === SOURCE_LOCALE || !isPublicAppLocale(locale)) {
return normalizedPath;
}
return normalizedPath === '/' ? `/${locale}` : `/${locale}${normalizedPath}`;
@@ -50,7 +53,8 @@ export function buildPageMetadata({
}: BuildPageMetadataInput): Metadata {
const siteUrl = getSiteUrl();
const normalizedPath = normalizePath(path);
const canonical = localizePath(locale, normalizedPath);
const metadataLocale = isPublicAppLocale(locale) ? locale : SOURCE_LOCALE;
const canonical = localizePath(metadataLocale, normalizedPath);
const ogImages =
ogImage === undefined
@@ -64,7 +68,7 @@ export function buildPageMetadata({
];
const baseMetadata: Metadata = {
title,
title: { absolute: title },
description,
alternates: {
canonical,
@@ -75,7 +79,7 @@ export function buildPageMetadata({
description,
url: canonical,
siteName: SITE_NAME,
locale,
locale: metadataLocale,
type,
...(ogImages && { images: ogImages }),
},
@@ -0,0 +1,26 @@
import { WEBSITE_ROUTES, type WebsiteRouteId } from '@/lib/website-routing';
import { buildLocalizedMetadata } from './build-localized-metadata';
import type { BuildPageMetadataInput } from './build-page-metadata';
type BuildRouteMetadataOptions = Partial<
Omit<BuildPageMetadataInput, 'locale' | 'path' | 'title' | 'description'>
>;
export const buildRouteMetadata = (
routeId: WebsiteRouteId,
options?: BuildRouteMetadataOptions,
) => {
const route = WEBSITE_ROUTES[routeId];
if (route === undefined) {
throw new Error(`Unknown website route: ${routeId}`);
}
return buildLocalizedMetadata({
path: route.path,
title: route.title,
description: route.description,
...options,
});
};
@@ -3,4 +3,5 @@ export {
buildPageMetadata,
type BuildPageMetadataInput,
} from './build-page-metadata';
export { buildRouteMetadata } from './build-route-metadata';
export { getAbsoluteUrl, getSiteUrl } from './site-url';
@@ -0,0 +1,205 @@
import {
createVisualRenderLoop,
type VisualRenderLoopDocument,
} from '../visual-render-loop';
function createAnimationFrameScheduler() {
let nextHandle = 1;
const callbacks = new Map<number, FrameRequestCallback>();
return {
cancelAnimationFrame: jest.fn((handle: number) => {
callbacks.delete(handle);
}),
requestAnimationFrame: jest.fn((callback: FrameRequestCallback) => {
const handle = nextHandle;
nextHandle += 1;
callbacks.set(handle, callback);
return handle;
}),
runFrame: (handle: number, timestamp = 0) => {
const callback = callbacks.get(handle);
callbacks.delete(handle);
callback?.(timestamp);
},
};
}
function createDocumentVisibilityStub(initialHidden = false) {
const listeners = new Set<EventListenerOrEventListenerObject>();
const documentStub: VisualRenderLoopDocument = {
addEventListener: jest.fn((_type, listener) => {
listeners.add(listener);
}),
hidden: initialHidden,
removeEventListener: jest.fn((_type, listener) => {
listeners.delete(listener);
}),
};
return {
documentStub,
setHidden: (hidden: boolean) => {
documentStub.hidden = hidden;
listeners.forEach((listener) => {
if (typeof listener === 'function') {
listener(new Event('visibilitychange'));
return;
}
listener.handleEvent(new Event('visibilitychange'));
});
},
};
}
describe('createVisualRenderLoop', () => {
it('schedules one frame at a time', () => {
const scheduler = createAnimationFrameScheduler();
const loop = createVisualRenderLoop({
cancelAnimationFrame: scheduler.cancelAnimationFrame,
document: null,
renderFrame: jest.fn(),
requestAnimationFrame: scheduler.requestAnimationFrame,
});
loop.start();
loop.start();
expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(1);
});
it('continues scheduling after each rendered frame', () => {
const scheduler = createAnimationFrameScheduler();
const renderFrame = jest.fn();
const loop = createVisualRenderLoop({
cancelAnimationFrame: scheduler.cancelAnimationFrame,
document: null,
renderFrame,
requestAnimationFrame: scheduler.requestAnimationFrame,
});
loop.start();
scheduler.runFrame(1, 16);
expect(renderFrame).toHaveBeenCalledWith(16, {
deltaSeconds: 0,
elapsedSeconds: 0,
timestamp: 16,
});
expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(2);
scheduler.runFrame(2, 32);
expect(renderFrame).toHaveBeenLastCalledWith(32, {
deltaSeconds: 0.016,
elapsedSeconds: 0.016,
timestamp: 32,
});
});
it('stops until explicitly restarted when a frame returns false', () => {
const scheduler = createAnimationFrameScheduler();
const renderFrame = jest.fn(() => false);
const loop = createVisualRenderLoop({
cancelAnimationFrame: scheduler.cancelAnimationFrame,
document: null,
renderFrame,
requestAnimationFrame: scheduler.requestAnimationFrame,
});
loop.start();
scheduler.runFrame(1, 16);
expect(renderFrame).toHaveBeenCalledWith(16, {
deltaSeconds: 0,
elapsedSeconds: 0,
timestamp: 16,
});
expect(loop.isRunning()).toBe(false);
expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(1);
loop.start();
expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(2);
});
it('pauses and resumes with document visibility', () => {
const scheduler = createAnimationFrameScheduler();
const { documentStub, setHidden } = createDocumentVisibilityStub();
const loop = createVisualRenderLoop({
cancelAnimationFrame: scheduler.cancelAnimationFrame,
document: documentStub,
renderFrame: jest.fn(),
requestAnimationFrame: scheduler.requestAnimationFrame,
});
loop.start();
setHidden(true);
expect(scheduler.cancelAnimationFrame).toHaveBeenCalledWith(1);
expect(loop.isRunning()).toBe(false);
setHidden(false);
expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(2);
});
it('pauses and resumes with target visibility', () => {
const scheduler = createAnimationFrameScheduler();
const targetVisibilityController: {
update: ((isVisible: boolean) => void) | null;
} = { update: null };
const loop = createVisualRenderLoop({
cancelAnimationFrame: scheduler.cancelAnimationFrame,
document: null,
renderFrame: jest.fn(),
requestAnimationFrame: scheduler.requestAnimationFrame,
target: {} as Element,
visibilityObserver: (_target, onVisibilityChange) => {
targetVisibilityController.update = onVisibilityChange;
return jest.fn();
},
});
loop.start();
const updateTargetVisibility = targetVisibilityController.update;
if (!updateTargetVisibility) {
throw new Error('Target visibility observer was not registered');
}
updateTargetVisibility(false);
expect(scheduler.cancelAnimationFrame).toHaveBeenCalledWith(1);
expect(loop.isRunning()).toBe(false);
updateTargetVisibility(true);
expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(2);
});
it('contains render errors and stops the loop', () => {
const scheduler = createAnimationFrameScheduler();
const error = new Error('frame failed');
const onFrameError = jest.fn();
const loop = createVisualRenderLoop({
cancelAnimationFrame: scheduler.cancelAnimationFrame,
document: null,
onFrameError,
renderFrame: () => {
throw error;
},
requestAnimationFrame: scheduler.requestAnimationFrame,
});
loop.start();
scheduler.runFrame(1, 16);
expect(onFrameError).toHaveBeenCalledWith(error);
expect(loop.isRunning()).toBe(false);
expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(1);
});
});
@@ -52,3 +52,24 @@ export function createSiteWebGlRenderer(
return renderer;
}
export type SiteWebGlRendererCreationFailureHandler = (error: unknown) => void;
export const reportSiteWebGlRendererCreationFailure: SiteWebGlRendererCreationFailureHandler =
(error) => {
if (process.env.NODE_ENV !== 'production') {
console.error('WebGL renderer creation failed:', error);
}
};
export function tryCreateSiteWebGlRenderer(
parameters?: SiteWebGlRendererParameters,
onError: SiteWebGlRendererCreationFailureHandler = reportSiteWebGlRendererCreationFailure,
): THREE.WebGLRenderer | null {
try {
return createSiteWebGlRenderer(parameters);
} catch (error) {
onError(error);
return null;
}
}
@@ -1,23 +0,0 @@
export type FrameTimer = {
tick: () => number;
getElapsed: () => number;
};
export function createFrameTimer(): FrameTimer {
const startedAt = performance.now();
let previousTickAt: number | null = null;
return {
tick: () => {
const now = performance.now();
if (previousTickAt === null) {
previousTickAt = now;
return 0;
}
const deltaSeconds = (now - previousTickAt) / 1000;
previousTickAt = now;
return deltaSeconds;
},
getElapsed: () => (performance.now() - startedAt) / 1000,
};
}
@@ -20,10 +20,24 @@ export { WebGlErrorBoundary } from './webgl-error-boundary';
export { WebGlMount } from './webgl-mount';
export {
createSiteWebGlRenderer,
reportSiteWebGlRendererCreationFailure,
tryCreateSiteWebGlRenderer,
type SiteWebGlRendererCreationFailureHandler,
type SiteWebGlRendererParameters,
} from './create-site-webgl-renderer';
export { createFrameTimer, type FrameTimer } from './frame-timer';
export {
createVisualRenderLoop,
reportVisualRenderLoopErrorInDevelopment,
type CreateVisualRenderLoopOptions,
type VisualRenderLoop,
type VisualRenderLoopCanceller,
type VisualRenderLoopDocument,
type VisualRenderLoopErrorHandler,
type VisualRenderLoopFrame,
type VisualRenderLoopFrameRenderer,
type VisualRenderLoopScheduler,
} from './visual-render-loop';
export {
createBoundedFailureCache,
@@ -1,2 +0,0 @@
export const GLASS_ENVIRONMENT_TEXTURE_URL =
'/illustrations/common/glass-environment.jpg' as const;
@@ -29,10 +29,19 @@ export function useWebGlPolicy(): WebGlPolicyDecision {
'(prefers-reduced-motion: reduce)',
);
const handleMotionChange = () => setDecision(evaluateWebGlPolicy());
mediaQueryList.addEventListener('change', handleMotionChange);
if (typeof mediaQueryList.addEventListener === 'function') {
mediaQueryList.addEventListener('change', handleMotionChange);
return () => {
mediaQueryList.removeEventListener('change', handleMotionChange);
};
}
mediaQueryList.addListener(handleMotionChange);
return () => {
mediaQueryList.removeEventListener('change', handleMotionChange);
mediaQueryList.removeListener(handleMotionChange);
};
}, []);
@@ -0,0 +1,221 @@
import {
observeElementVisibility,
type ObserveElementVisibilityOptions,
} from '@/lib/dom/observe-element-visibility';
import { runCleanupTasks } from '@/lib/lifecycle/run-cleanup-tasks';
export type VisualRenderLoop = {
dispose: () => void;
isRunning: () => boolean;
start: () => void;
stop: () => void;
};
export type VisualRenderLoopErrorHandler = (error: unknown) => void;
export type VisualRenderLoopDocument = {
addEventListener: Document['addEventListener'];
hidden: boolean;
removeEventListener: Document['removeEventListener'];
};
export type VisualRenderLoopScheduler = (
callback: FrameRequestCallback,
) => number;
export type VisualRenderLoopCanceller = (handle: number) => void;
export type VisualRenderLoopFrame = {
deltaSeconds: number;
elapsedSeconds: number;
timestamp: DOMHighResTimeStamp;
};
export type VisualRenderLoopFrameRenderer = (
timestamp: DOMHighResTimeStamp,
frame: VisualRenderLoopFrame,
) => boolean | void;
export type CreateVisualRenderLoopOptions = {
cancelAnimationFrame?: VisualRenderLoopCanceller;
document?: VisualRenderLoopDocument | null;
onFrameError?: VisualRenderLoopErrorHandler;
pauseWhenDocumentHidden?: boolean;
renderFrame: VisualRenderLoopFrameRenderer;
requestAnimationFrame?: VisualRenderLoopScheduler;
shouldRender?: () => boolean;
target?: Element | null;
targetVisibilityOptions?: ObserveElementVisibilityOptions;
visibilityObserver?: typeof observeElementVisibility;
};
export const reportVisualRenderLoopErrorInDevelopment: VisualRenderLoopErrorHandler =
(error) => {
if (process.env.NODE_ENV !== 'production') {
console.error('Visual render loop failed:', error);
}
};
const MAX_FRAME_DELTA_SECONDS = 0.1;
function getDefaultDocument(): VisualRenderLoopDocument | null {
return typeof document === 'undefined' ? null : document;
}
function requestAnimationFrameFromWindow(callback: FrameRequestCallback) {
return window.requestAnimationFrame(callback);
}
function cancelAnimationFrameFromWindow(handle: number) {
window.cancelAnimationFrame(handle);
}
export function createVisualRenderLoop({
cancelAnimationFrame = cancelAnimationFrameFromWindow,
document: documentReference = getDefaultDocument(),
onFrameError = reportVisualRenderLoopErrorInDevelopment,
pauseWhenDocumentHidden = true,
renderFrame,
requestAnimationFrame = requestAnimationFrameFromWindow,
shouldRender,
target = null,
targetVisibilityOptions,
visibilityObserver = observeElementVisibility,
}: CreateVisualRenderLoopOptions): VisualRenderLoop {
let disposed = false;
let frameId: number | null = null;
let isDocumentVisible =
!pauseWhenDocumentHidden || !documentReference?.hidden;
let isTargetVisible = true;
let firstFrameAt: DOMHighResTimeStamp | null = null;
let previousFrameAt: DOMHighResTimeStamp | null = null;
let wantsRunning = false;
const cleanupTasks: Array<() => void> = [];
const canRender = () =>
wantsRunning &&
!disposed &&
isDocumentVisible &&
isTargetVisible &&
(shouldRender?.() ?? true);
const cancelPendingFrame = () => {
if (frameId === null) {
return;
}
cancelAnimationFrame(frameId);
frameId = null;
previousFrameAt = null;
};
const scheduleNextFrame = () => {
if (frameId !== null || !canRender()) {
return;
}
frameId = requestAnimationFrame((timestamp) => {
frameId = null;
if (!canRender()) {
return;
}
try {
if (firstFrameAt === null) {
firstFrameAt = timestamp;
}
const deltaSeconds =
previousFrameAt === null
? 0
: Math.min(
Math.max((timestamp - previousFrameAt) / 1000, 0),
MAX_FRAME_DELTA_SECONDS,
);
previousFrameAt = timestamp;
if (
renderFrame(timestamp, {
deltaSeconds,
elapsedSeconds: Math.max((timestamp - firstFrameAt) / 1000, 0),
timestamp,
}) === false
) {
wantsRunning = false;
previousFrameAt = null;
return;
}
} catch (error) {
wantsRunning = false;
previousFrameAt = null;
onFrameError(error);
return;
}
scheduleNextFrame();
});
};
const syncSchedule = () => {
if (canRender()) {
scheduleNextFrame();
return;
}
cancelPendingFrame();
};
if (pauseWhenDocumentHidden && documentReference) {
const handleVisibilityChange = () => {
isDocumentVisible = !documentReference.hidden;
syncSchedule();
};
documentReference.addEventListener(
'visibilitychange',
handleVisibilityChange,
);
cleanupTasks.push(() =>
documentReference.removeEventListener(
'visibilitychange',
handleVisibilityChange,
),
);
}
if (target) {
cleanupTasks.push(
visibilityObserver(
target,
(isVisible) => {
isTargetVisible = isVisible;
syncSchedule();
},
targetVisibilityOptions,
),
);
}
const stop = () => {
wantsRunning = false;
cancelPendingFrame();
};
return {
dispose: () => {
if (disposed) {
return;
}
disposed = true;
stop();
runCleanupTasks(cleanupTasks);
},
isRunning: () => frameId !== null,
start: () => {
wantsRunning = true;
syncSchedule();
},
stop,
};
}
@@ -3,6 +3,8 @@
import { styled } from '@linaria/react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { observeElementVisibility } from '@/lib/dom/observe-element-visibility';
import {
subscribeToActiveWebGlContextCount,
tryReserveWebGlContextSlot,
@@ -76,9 +78,10 @@ export function WebGlMount({
}
};
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
const stopObservingVisibility = observeElementVisibility(
element,
(isIntersecting) => {
if (isIntersecting) {
clearDisposeTimer();
setIsInViewport(true);
return;
@@ -93,11 +96,9 @@ export function WebGlMount({
{ root: null, rootMargin: NON_PRIORITY_ROOT_MARGIN, threshold: 0 },
);
observer.observe(element);
return () => {
clearDisposeTimer();
observer.disconnect();
stopObservingVisibility();
};
}, [priority]);
@@ -0,0 +1,48 @@
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
import {
WEBSITE_ROUTE_LIST,
WEBSITE_ROUTES,
getIndexedWebsiteRoutes,
getRobotsDisallowedRoutePaths,
} from '..';
describe('website route registry', () => {
it('keeps route ids and paths unique', () => {
const routeIds = WEBSITE_ROUTE_LIST.map((route) => route.id);
const routePaths = WEBSITE_ROUTE_LIST.map((route) => route.path);
expect(new Set(routeIds).size).toBe(routeIds.length);
expect(new Set(routePaths).size).toBe(routePaths.length);
});
it('registers every customer story from the catalog', () => {
const routePaths = new Set(WEBSITE_ROUTE_LIST.map((route) => route.path));
for (const entry of CASE_STUDY_CATALOG_ENTRIES) {
expect(routePaths.has(entry.href)).toBe(true);
expect(
WEBSITE_ROUTES[`customer:${entry.href.slice('/customers/'.length)}`],
).toMatchObject({
path: entry.href,
indexed: true,
});
}
});
it('excludes private utility routes from indexed routes', () => {
const indexedPaths = getIndexedWebsiteRoutes().map((route) => route.path);
expect(indexedPaths).toContain('/');
expect(indexedPaths).toContain('/product');
expect(indexedPaths).not.toContain('/halftone');
expect(indexedPaths).not.toContain('/enterprise/activate');
});
it('derives robots disallow paths from routes marked private', () => {
expect(getRobotsDisallowedRoutePaths()).toEqual([
'/halftone',
'/enterprise/activate',
]);
});
});
@@ -0,0 +1,14 @@
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
import type { WebsiteRoute } from './types';
export const CUSTOMER_STORY_ROUTES: readonly WebsiteRoute[] =
CASE_STUDY_CATALOG_ENTRIES.map((entry) => ({
id: `customer:${entry.href.slice('/customers/'.length)}`,
path: entry.href,
title: `${entry.hero.author} | Twenty Customer Story`,
description: entry.catalogCard.summary,
changeFrequency: 'yearly',
priority: 0.5,
indexed: true,
}));
@@ -0,0 +1,9 @@
export { CUSTOMER_STORY_ROUTES } from './customer-story-routes';
export { STATIC_WEBSITE_ROUTES } from './static-website-routes';
export type { WebsiteRoute, WebsiteRouteId } from './types';
export { WEBSITE_ROUTE_LIST } from './website-route-list';
export { WEBSITE_ROUTE_BY_ID, WEBSITE_ROUTES } from './website-route-map';
export {
getIndexedWebsiteRoutes,
getRobotsDisallowedRoutePaths,
} from './website-route-selectors';
@@ -0,0 +1,115 @@
import type { WebsiteRoute } from './types';
export const STATIC_WEBSITE_ROUTES = [
{
id: 'home',
path: '/',
title: 'Twenty | #1 open source CRM',
description:
'The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.',
changeFrequency: 'weekly',
priority: 1,
indexed: true,
},
{
id: 'whyTwenty',
path: '/why-twenty',
title: 'Why Twenty | Twenty',
description:
'Most packaged software makes companies more similar. Learn why the future of CRM is built, not bought.',
changeFrequency: 'monthly',
priority: 0.8,
indexed: true,
},
{
id: 'product',
path: '/product',
title: 'Product | Twenty',
description:
'Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.',
changeFrequency: 'monthly',
priority: 0.8,
indexed: true,
},
{
id: 'pricing',
path: '/pricing',
title: 'Pricing | Twenty',
description:
'Plans that scale with your team. Compare tiers of the #1 open source CRM.',
changeFrequency: 'monthly',
priority: 0.9,
indexed: true,
},
{
id: 'partners',
path: '/partners',
title: 'Partners | Twenty',
description:
'Join our partner ecosystem and grow with us as we build the #1 open source CRM.',
changeFrequency: 'monthly',
priority: 0.7,
indexed: true,
},
{
id: 'releases',
path: '/releases',
title: 'Releases | Twenty',
description:
'Discover the newest features and improvements in Twenty, the #1 open source CRM.',
changeFrequency: 'weekly',
priority: 0.7,
indexed: true,
},
{
id: 'customers',
path: '/customers',
title: 'Customers | Twenty',
description:
'Meet the teams running their business on Twenty. Real customer stories on how they shaped the CRM to fit their workflow.',
changeFrequency: 'monthly',
priority: 0.7,
indexed: true,
},
{
id: 'privacyPolicy',
path: '/privacy-policy',
title: 'Privacy Policy | Twenty',
description:
'How Twenty collects, uses, safeguards, and discloses information when you use Twenty.com and related services.',
changeFrequency: 'yearly',
priority: 0.3,
indexed: true,
},
{
id: 'terms',
path: '/terms',
title: 'Terms of Service | Twenty',
description:
'Terms of Service for Twenty.com PBC, including use of Twenty.com, sub-domains, and related services.',
changeFrequency: 'yearly',
priority: 0.3,
indexed: true,
},
{
id: 'halftone',
path: '/halftone',
title: 'Halftone Generator | Twenty',
description: 'Interactive halftone generator exported from Twenty.',
changeFrequency: 'monthly',
priority: 0,
indexed: false,
robotsDisallow: true,
},
{
id: 'enterpriseActivate',
path: '/enterprise/activate',
title: 'Enterprise activation | Twenty',
description:
'Complete activation for your Twenty self-hosted enterprise license.',
changeFrequency: 'yearly',
priority: 0,
indexed: false,
robotsDisallow: true,
},
] as const satisfies readonly WebsiteRoute[];
@@ -0,0 +1,26 @@
import type { MetadataRoute } from 'next';
export type WebsiteRouteId =
| 'home'
| 'whyTwenty'
| 'product'
| 'pricing'
| 'partners'
| 'releases'
| 'customers'
| 'privacyPolicy'
| 'terms'
| 'halftone'
| 'enterpriseActivate'
| `customer:${string}`;
export type WebsiteRoute = {
changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'];
description: string;
id: WebsiteRouteId;
indexed: boolean;
path: string;
priority: number;
robotsDisallow?: boolean;
title: string;
};
@@ -0,0 +1,8 @@
import { CUSTOMER_STORY_ROUTES } from './customer-story-routes';
import { STATIC_WEBSITE_ROUTES } from './static-website-routes';
import type { WebsiteRoute } from './types';
export const WEBSITE_ROUTE_LIST: readonly WebsiteRoute[] = [
...STATIC_WEBSITE_ROUTES,
...CUSTOMER_STORY_ROUTES,
];
@@ -0,0 +1,10 @@
import type { WebsiteRoute, WebsiteRouteId } from './types';
import { WEBSITE_ROUTE_LIST } from './website-route-list';
export const WEBSITE_ROUTES: Readonly<
Partial<Record<WebsiteRouteId, WebsiteRoute>>
> = Object.fromEntries(
WEBSITE_ROUTE_LIST.map((route) => [route.id, route]),
) as Readonly<Partial<Record<WebsiteRouteId, WebsiteRoute>>>;
export const WEBSITE_ROUTE_BY_ID = WEBSITE_ROUTES;
@@ -0,0 +1,10 @@
import { WEBSITE_ROUTE_LIST } from './website-route-list';
import type { WebsiteRoute } from './types';
export const getIndexedWebsiteRoutes = (): readonly WebsiteRoute[] =>
WEBSITE_ROUTE_LIST.filter((route) => route.indexed);
export const getRobotsDisallowedRoutePaths = (): readonly string[] =>
WEBSITE_ROUTE_LIST.filter((route) => route.robotsDisallow).map(
(route) => route.path,
);
@@ -1,7 +1,10 @@
import { NextResponse, type NextRequest } from 'next/server';
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import { APP_LOCALE_BY_RAW } from '@/lib/i18n/app-locale-set';
import {
APP_LOCALE_BY_RAW,
KNOWN_PUBLIC_APP_LOCALE_BY_RAW,
} from '@/lib/i18n/app-locale-set';
import { LOCALE_COOKIE_NAME, detectLocale } from '@/lib/i18n/detect-locale';
const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
@@ -16,13 +19,14 @@ const setLocaleCookie = (response: NextResponse, locale: AppLocale) => {
});
};
export const middleware = (request: NextRequest) => {
export const proxy = (request: NextRequest) => {
const { pathname, search } = request.nextUrl;
const firstSlash = pathname.indexOf('/', 1);
const firstSegment =
firstSlash === -1 ? pathname.slice(1) : pathname.slice(1, firstSlash);
const localeFromPath = APP_LOCALE_BY_RAW.get(firstSegment);
const knownLocaleFromPath = KNOWN_PUBLIC_APP_LOCALE_BY_RAW.get(firstSegment);
if (localeFromPath === SOURCE_LOCALE) {
const canonicalPath = firstSlash === -1 ? '/' : pathname.slice(firstSlash);
@@ -42,6 +46,14 @@ export const middleware = (request: NextRequest) => {
return response;
}
if (knownLocaleFromPath !== undefined) {
const canonicalPath = firstSlash === -1 ? '/' : pathname.slice(firstSlash);
const target = request.nextUrl.clone();
target.pathname = canonicalPath;
target.search = search;
return NextResponse.redirect(target, 308);
}
const detected = detectLocale({
cookieValue: request.cookies.get(LOCALE_COOKIE_NAME)?.value,
acceptLanguageHeader: request.headers.get('accept-language') ?? undefined,
@@ -1,503 +1,19 @@
// @ts-nocheck
'use client';
import { useEffect, useRef, type CSSProperties } from 'react';
import * as THREE from 'three';
import { HalftoneImageCanvas } from '@/lib/halftone';
import { styled } from '@linaria/react';
import { createSiteWebGlRenderer } from '@/lib/visual-runtime';
const DEFAULT_DASH_COLOR = '#4A38F5';
const DEFAULT_HOVER_DASH_COLOR = '#8B7FF8';
const CSS_VAR_PATTERN = /^var\(\s*(--[^,)]+)(?:\s*,\s*([^)]+))?\s*\)$/;
function resolveColorValue(value: string, fallback: string) {
if (typeof value !== 'string') {
return fallback;
}
const match = value.trim().match(CSS_VAR_PATTERN);
if (!match) {
return value;
}
if (typeof window === 'undefined') {
return match[2]?.trim() ?? fallback;
}
const resolved = getComputedStyle(document.documentElement)
.getPropertyValue(match[1])
.trim();
return resolved || match[2]?.trim() || fallback;
}
const settings = {
halftone: {
scale: 24.72,
power: -0.07,
toneTarget: 'light',
width: 0.46,
imageContrast: 1,
},
animation: {
hoverFadeIn: 10,
hoverFadeOut: 5,
hoverHalftonePowerShift: 0.45,
hoverHalftoneRadius: 0.6,
hoverHalftoneWidthShift: -0.1,
pointerFollow: 0.3,
waveSpeed: 1,
},
};
const previewDistance = 4;
const VIRTUAL_RENDER_HEIGHT = 512;
const REFERENCE_CONTAINER_HEIGHT = 240;
const passThroughVertexShader =
'\n varying vec2 vUv;\n\n void main() {\n vUv = uv;\n gl_Position = vec4(position, 1.0);\n }\n';
const blurFragmentShader =
'\n precision highp float;\n\n uniform sampler2D tInput;\n uniform vec2 dir;\n uniform vec2 res;\n\n varying vec2 vUv;\n\n void main() {\n vec4 sum = vec4(0.0);\n vec2 px = dir / res;\n\n float w[5];\n w[0] = 0.227027;\n w[1] = 0.1945946;\n w[2] = 0.1216216;\n w[3] = 0.054054;\n w[4] = 0.016216;\n\n sum += texture2D(tInput, vUv) * w[0];\n\n for (int i = 1; i < 5; i++) {\n float fi = float(i) * 3.0;\n sum += texture2D(tInput, vUv + px * fi) * w[i];\n sum += texture2D(tInput, vUv - px * fi) * w[i];\n }\n\n gl_FragColor = sum;\n }\n';
const halftoneFragmentShader =
'\n precision highp float;\n\n uniform sampler2D tScene;\n uniform vec2 effectResolution;\n uniform vec2 logicalResolution;\n uniform float tile;\n uniform float s_3;\n uniform float s_4;\n uniform float applyToDarkAreas;\n uniform vec3 dashColor;\n uniform vec3 hoverDashColor;\n uniform float time;\n uniform float waveAmount;\n uniform float waveSpeed;\n uniform float footprintScale;\n uniform vec2 interactionUv;\n uniform float hoverHalftoneActive;\n uniform float hoverHalftonePowerShift;\n uniform float hoverHalftoneRadius;\n uniform float hoverHalftoneWidthShift;\n\n varying vec2 vUv;\n\n float distSegment(in vec2 p, in vec2 a, in vec2 b) {\n vec2 pa = p - a;\n vec2 ba = b - a;\n float denom = max(dot(ba, ba), 0.000001);\n float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);\n return length(pa - ba * h);\n }\n\n float lineSimpleEt(in vec2 p, in float r, in float thickness) {\n vec2 a = vec2(0.5) + vec2(-r, 0.0);\n vec2 b = vec2(0.5) + vec2(r, 0.0);\n float distToSegment = distSegment(p, a, b);\n float halfThickness = thickness * r;\n return distToSegment - halfThickness;\n }\n\n void main() {\n vec2 fragCoord =\n (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;\n float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);\n\n float bandRow = floor(fragCoord.y / halftoneSize);\n float waveOffset =\n waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;\n vec2 effectCoord = fragCoord;\n effectCoord.x += waveOffset;\n\n vec2 cellIndex = floor(effectCoord / halftoneSize);\n vec2 sampleUv = clamp(\n (cellIndex + 0.5) * halftoneSize / logicalResolution,\n vec2(0.0),\n vec2(1.0)\n );\n vec2 cellUv = fract(effectCoord / halftoneSize);\n\n float hoverHalftoneMask = 0.0;\n if (hoverHalftoneActive > 0.0) {\n vec2 pointerPx = interactionUv * logicalResolution;\n float fragDist = length(fragCoord - pointerPx);\n float hoverRadiusPx = hoverHalftoneRadius * logicalResolution.y;\n hoverHalftoneMask =\n smoothstep(hoverRadiusPx, 0.0, fragDist) *\n clamp(hoverHalftoneActive, 0.0, 1.0);\n }\n\n vec4 sceneSample = texture2D(tScene, sampleUv);\n float toneValue =\n (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0);\n if (applyToDarkAreas > 0.5) {\n toneValue = 1.0 - toneValue;\n }\n float localPower = clamp(\n s_3 + hoverHalftonePowerShift * hoverHalftoneMask,\n -1.5,\n 1.5\n );\n float localWidth = clamp(\n s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,\n 0.05,\n 1.4\n );\n float powerBias = localPower * length(vec2(0.5)) * (1.0 / 3.0);\n float bandRadius = clamp(\n toneValue + powerBias,\n 0.0,\n 1.0\n ) * 1.86 * 0.5;\n\n float alpha = 0.0;\n if (bandRadius > 0.0001) {\n float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);\n float edge = 0.02;\n alpha = 1.0 - smoothstep(0.0, edge, signedDistance);\n }\n\n vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);\n vec3 color = activeDashColor * alpha;\n gl_FragColor = vec4(color, alpha);\n\n #include <tonemapping_fragment>\n #include <colorspace_fragment>\n }\n';
const imagePassthroughFragmentShader =
'\n precision highp float;\n\n uniform sampler2D tImage;\n uniform vec2 imageSize;\n uniform vec2 viewportSize;\n uniform float zoom;\n uniform float contrast;\n\n varying vec2 vUv;\n\n void main() {\n float imageAspect = imageSize.x / imageSize.y;\n float viewAspect = viewportSize.x / viewportSize.y;\n\n vec2 uv = vUv;\n\n if (imageAspect > viewAspect) {\n float scale = viewAspect / imageAspect;\n uv.x = (uv.x - 0.5) * scale + 0.5;\n } else {\n float scale = imageAspect / viewAspect;\n uv.y = (uv.y - 0.5) * scale + 0.5;\n }\n\n uv = (uv - 0.5) / zoom + 0.5;\n\n vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));\n vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);\n\n gl_FragColor = vec4(contrastColor, 1.0);\n }\n';
const REFERENCE_PREVIEW_DISTANCE = 4;
const MIN_FOOTPRINT_SCALE = 0.001;
function clampRectToViewport(rect, viewportWidth, viewportHeight) {
const minX = Math.max(rect.x, 0);
const minY = Math.max(rect.y, 0);
const maxX = Math.min(rect.x + rect.width, viewportWidth);
const maxY = Math.min(rect.y + rect.height, viewportHeight);
if (maxX <= minX || maxY <= minY) {
return null;
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
function getRectArea(rect) {
if (!rect) {
return 0;
}
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
}
function getImagePreviewZoom(distance) {
return REFERENCE_PREVIEW_DISTANCE / Math.max(distance, 0.001);
}
function getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom,
}) {
if (
imageWidth <= 0 ||
imageHeight <= 0 ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return null;
}
const imageAspect = imageWidth / imageHeight;
const viewAspect = viewportWidth / viewportHeight;
let fittedWidth = viewportWidth;
let fittedHeight = viewportHeight;
if (imageAspect > viewAspect) {
fittedHeight = viewportWidth / imageAspect;
} else {
fittedWidth = viewportHeight * imageAspect;
}
const scaledWidth = fittedWidth * zoom;
const scaledHeight = fittedHeight * zoom;
return clampRectToViewport(
{
x: (viewportWidth - scaledWidth) * 0.5,
y: (viewportHeight - scaledHeight) * 0.5,
width: scaledWidth,
height: scaledHeight,
},
viewportWidth,
viewportHeight,
);
}
function getFootprintScaleFromRects(currentRect, referenceRect) {
const currentArea = getRectArea(currentRect);
const referenceArea = getRectArea(referenceRect);
if (currentArea <= 0 || referenceArea <= 0) {
return 1;
}
return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE);
}
function getImageFootprintScale({
imageHeight,
imageWidth,
distance,
viewportHeight,
viewportWidth,
}) {
const currentRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: getImagePreviewZoom(distance),
});
const referenceRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: 1,
});
return getFootprintScaleFromRects(currentRect, referenceRect);
}
function createRenderTarget(width, height) {
return new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
});
}
async function mountHalftoneCanvas(options) {
const { container, imageUrl } = options;
const dashColor = resolveColorValue(options.dashColor, DEFAULT_DASH_COLOR);
const hoverDashColor = resolveColorValue(
options.hoverDashColor,
DEFAULT_HOVER_DASH_COLOR,
);
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
const getVirtualWidth = () =>
Math.max(
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
1,
);
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Failed to load image'));
img.src = imageUrl;
});
const renderer = createSiteWebGlRenderer({ 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.display = 'block';
canvas.style.height = '100%';
canvas.style.width = '100%';
container.appendChild(canvas);
const imageTexture = new THREE.Texture(image);
imageTexture.colorSpace = THREE.SRGBColorSpace;
imageTexture.needsUpdate = true;
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const imageMaterial = new THREE.ShaderMaterial({
uniforms: {
tImage: { value: imageTexture },
imageSize: { value: new THREE.Vector2(image.width, image.height) },
viewportSize: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
zoom: { value: getImagePreviewZoom(previewDistance) },
contrast: { value: settings.halftone.imageContrast },
},
vertexShader: passThroughVertexShader,
fragmentShader: imagePassthroughFragmentShader,
});
const imageScene = new THREE.Scene();
imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
const blurHorizontalMaterial = new THREE.ShaderMaterial({
uniforms: {
tInput: { value: null },
dir: { value: new THREE.Vector2(1, 0) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
},
vertexShader: passThroughVertexShader,
fragmentShader: blurFragmentShader,
});
const blurVerticalMaterial = new THREE.ShaderMaterial({
uniforms: {
tInput: { value: null },
dir: { value: new THREE.Vector2(0, 1) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
},
vertexShader: passThroughVertexShader,
fragmentShader: blurFragmentShader,
});
const halftoneMaterial = new THREE.ShaderMaterial({
transparent: true,
uniforms: {
tScene: { value: sceneTarget.texture },
effectResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
logicalResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
tile: { value: settings.halftone.scale },
s_3: { value: settings.halftone.power },
s_4: { value: settings.halftone.width },
applyToDarkAreas: {
value: settings.halftone.toneTarget === 'dark' ? 1 : 0,
},
dashColor: { value: new THREE.Color(dashColor) },
hoverDashColor: {
value: new THREE.Color(hoverDashColor),
},
time: { value: 0 },
waveAmount: { value: 0 },
waveSpeed: { value: settings.animation.waveSpeed },
footprintScale: { value: 1.0 },
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
hoverHalftoneActive: { value: 0 },
hoverHalftonePowerShift: {
value: settings.animation.hoverHalftonePowerShift,
},
hoverHalftoneRadius: { value: settings.animation.hoverHalftoneRadius },
hoverHalftoneWidthShift: {
value: settings.animation.hoverHalftoneWidthShift,
},
},
vertexShader: passThroughVertexShader,
fragmentShader: halftoneFragmentShader,
});
const blurHorizontalScene = new THREE.Scene();
blurHorizontalScene.add(
new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
);
const blurVerticalScene = new THREE.Scene();
blurVerticalScene.add(
new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial),
);
const postScene = new THREE.Scene();
postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
const updateViewportUniforms = (
logicalWidth,
logicalHeight,
effectWidth,
effectHeight,
) => {
blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
halftoneMaterial.uniforms.effectResolution.value.set(
effectWidth,
effectHeight,
);
halftoneMaterial.uniforms.logicalResolution.value.set(
logicalWidth,
logicalHeight,
);
imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
};
const getHalftoneScale = () =>
getImageFootprintScale({
imageHeight: image.height,
imageWidth: image.width,
distance: previewDistance,
viewportHeight: getVirtualHeight(),
viewportWidth: getVirtualWidth(),
});
const TARGET_TILE_CSS_SIZE =
(settings.halftone.scale * REFERENCE_CONTAINER_HEIGHT) /
VIRTUAL_RENDER_HEIGHT;
const updateTileUniform = () => {
const virtualHeight = getVirtualHeight();
const containerHeight = Math.max(getHeight(), 1);
halftoneMaterial.uniforms.tile.value =
(TARGET_TILE_CSS_SIZE * virtualHeight) / containerHeight;
};
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,
);
updateTileUniform();
};
updateTileUniform();
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
const interaction = {
pointerInside: false,
mouseX: 0.5,
mouseY: 0.5,
smoothedMouseX: 0.5,
smoothedMouseY: 0.5,
hoverStrength: 0,
};
const pointerFollow = settings.animation.pointerFollow;
const hoverFadeIn = settings.animation.hoverFadeIn;
const hoverFadeOut = settings.animation.hoverFadeOut;
const updatePointer = (event) => {
const rect = container.getBoundingClientRect();
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
interaction.mouseX = THREE.MathUtils.clamp(
(event.clientX - rect.left) / width,
0,
1,
);
interaction.mouseY = THREE.MathUtils.clamp(
(event.clientY - rect.top) / height,
0,
1,
);
};
const handlePointerEnter = (event) => {
updatePointer(event);
interaction.smoothedMouseX = interaction.mouseX;
interaction.smoothedMouseY = interaction.mouseY;
interaction.pointerInside = true;
};
const handlePointerMove = (event) => {
updatePointer(event);
interaction.pointerInside = true;
};
const handlePointerLeave = () => {
interaction.pointerInside = false;
};
container.addEventListener('pointerenter', handlePointerEnter);
container.addEventListener('pointermove', handlePointerMove);
container.addEventListener('pointerleave', handlePointerLeave);
const clock = new THREE.Timer();
clock.connect(document);
let animationFrameId = 0;
const renderFrame = (timestamp) => {
animationFrameId = window.requestAnimationFrame(renderFrame);
clock.update(timestamp);
const deltaSeconds = clock.getDelta();
const hoverEasing =
1 -
Math.exp(
-deltaSeconds *
(interaction.pointerInside ? hoverFadeIn : hoverFadeOut),
);
interaction.hoverStrength +=
((interaction.pointerInside ? 1 : 0) - interaction.hoverStrength) *
hoverEasing;
interaction.smoothedMouseX +=
(interaction.mouseX - interaction.smoothedMouseX) * pointerFollow;
interaction.smoothedMouseY +=
(interaction.mouseY - interaction.smoothedMouseY) * pointerFollow;
halftoneMaterial.uniforms.interactionUv.value.set(
interaction.smoothedMouseX,
1 - interaction.smoothedMouseY,
);
halftoneMaterial.uniforms.hoverHalftoneActive.value =
interaction.hoverStrength;
halftoneMaterial.uniforms.time.value = clock.getElapsed();
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(previewDistance);
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
renderer.setRenderTarget(sceneTarget);
renderer.render(imageScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(postScene, orthographicCamera);
};
renderFrame();
return () => {
window.cancelAnimationFrame(animationFrameId);
clock.dispose();
resizeObserver.disconnect();
container.removeEventListener('pointerenter', handlePointerEnter);
container.removeEventListener('pointermove', handlePointerMove);
container.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);
}
};
}
import { type CSSProperties } from 'react';
import {
buildCustomerCasesCoverSettings,
CUSTOMER_CASES_COVER_DEFAULT_DASH_COLOR,
CUSTOMER_CASES_COVER_DEFAULT_HOVER_DASH_COLOR,
CUSTOMER_CASES_COVER_IMAGE_FIT,
CUSTOMER_CASES_COVER_IMAGE_INTERACTION,
CUSTOMER_CASES_COVER_PREVIEW_DISTANCE,
CUSTOMER_CASES_COVER_VIRTUAL_RENDER_HEIGHT,
resolveColorValue,
} from './customer-cases-cover-config';
const StyledVisualMount = styled.div`
background: #000;
@@ -508,43 +24,41 @@ const StyledVisualMount = styled.div`
`;
type CustomerCasesCoverProps = {
imageUrl: string;
style?: CSSProperties;
dashColor?: string;
hoverDashColor?: string;
imageUrl: string;
style?: CSSProperties;
};
export function CustomerCasesCover({
dashColor = CUSTOMER_CASES_COVER_DEFAULT_DASH_COLOR,
hoverDashColor = CUSTOMER_CASES_COVER_DEFAULT_HOVER_DASH_COLOR,
imageUrl,
style,
dashColor = DEFAULT_DASH_COLOR,
hoverDashColor = DEFAULT_HOVER_DASH_COLOR,
}: CustomerCasesCoverProps) {
const mountReference = useRef<HTMLDivElement>(null);
const resolvedDashColor = resolveColorValue(
dashColor,
CUSTOMER_CASES_COVER_DEFAULT_DASH_COLOR,
);
const resolvedHoverDashColor = resolveColorValue(
hoverDashColor,
CUSTOMER_CASES_COVER_DEFAULT_HOVER_DASH_COLOR,
);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
const unmountPromise = mountHalftoneCanvas({
container,
imageUrl,
dashColor,
hoverDashColor,
}).catch((error) => {
if (process.env.NODE_ENV !== 'production') {
console.error(error);
}
return undefined;
});
return () => {
void unmountPromise.then((dispose) => dispose?.());
};
}, [imageUrl, dashColor, hoverDashColor]);
return <StyledVisualMount aria-hidden ref={mountReference} style={style} />;
return (
<StyledVisualMount aria-hidden style={style}>
<HalftoneImageCanvas
crossOrigin="anonymous"
imageFit={CUSTOMER_CASES_COVER_IMAGE_FIT}
imageInteraction={CUSTOMER_CASES_COVER_IMAGE_INTERACTION}
imageUrl={imageUrl}
previewDistance={CUSTOMER_CASES_COVER_PREVIEW_DISTANCE}
settings={buildCustomerCasesCoverSettings({
dashColor: resolvedDashColor,
hoverDashColor: resolvedHoverDashColor,
})}
virtualRenderHeight={CUSTOMER_CASES_COVER_VIRTUAL_RENDER_HEIGHT}
/>
</StyledVisualMount>
);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,136 @@
import {
normalizeHalftoneStudioSettings,
type HalftoneImageFit,
type HalftoneImageInteractionSettings,
type HalftoneStudioSettings,
} from '@/lib/halftone';
export const CUSTOMER_CASES_COVER_DEFAULT_DASH_COLOR = '#4A38F5';
export const CUSTOMER_CASES_COVER_DEFAULT_HOVER_DASH_COLOR = '#8B7FF8';
export const CUSTOMER_CASES_COVER_IMAGE_FIT: HalftoneImageFit = 'cover';
export const CUSTOMER_CASES_COVER_PREVIEW_DISTANCE = 4;
export const CUSTOMER_CASES_COVER_VIRTUAL_RENDER_HEIGHT = 512;
export const CUSTOMER_CASES_COVER_IMAGE_INTERACTION = {
hoverFadeIn: 10,
hoverFadeOut: 5,
pointerFollow: 0.3,
} satisfies Partial<HalftoneImageInteractionSettings>;
const CSS_VAR_PATTERN = /^var\(\s*(--[^,)]+)(?:\s*,\s*([^)]+))?\s*\)$/;
export function resolveColorValue(value: string, fallback: string) {
if (typeof value !== 'string') {
return fallback;
}
const match = value.trim().match(CSS_VAR_PATTERN);
if (!match) {
return value;
}
if (typeof window === 'undefined') {
return match[2]?.trim() ?? fallback;
}
const resolved = getComputedStyle(document.documentElement)
.getPropertyValue(match[1])
.trim();
return resolved || match[2]?.trim() || fallback;
}
export const buildCustomerCasesCoverSettings = ({
dashColor,
hoverDashColor,
}: {
dashColor: string;
hoverDashColor: string;
}): HalftoneStudioSettings =>
normalizeHalftoneStudioSettings({
sourceMode: 'image',
shapeKey: 'torusKnot',
lighting: {
intensity: 1.5,
fillIntensity: 0.15,
ambientIntensity: 0.08,
angleDegrees: 45,
height: 2,
},
material: {
surface: 'solid',
color: '#d4d0c8',
roughness: 0.42,
metalness: 0.16,
thickness: 150,
refraction: 2,
environmentPower: 5,
},
halftone: {
enabled: true,
scale: 24.72,
power: -0.07,
toneTarget: 'light',
width: 0.46,
imageContrast: 1,
dashColor,
hoverDashColor,
},
background: {
transparent: true,
color: '#000000',
},
animation: {
autoRotateEnabled: true,
breatheEnabled: false,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: false,
floatEnabled: false,
hoverHalftoneEnabled: true,
hoverLightEnabled: false,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.2,
autoWobble: 0.3,
breatheAmount: 0.04,
breatheSpeed: 0.8,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 0.2,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepRange: 28,
lightSweepSpeed: 0.7,
springDamping: 0.72,
springReturnEnabled: false,
springStrength: 0.18,
hoverHalftonePowerShift: 0.45,
hoverHalftoneRadius: 0.6,
hoverHalftoneWidthShift: -0.1,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
dragFlowRadius: 0.24,
dragFlowStrength: 1.8,
hoverWarpStrength: 3,
hoverWarpRadius: 0.15,
dragWarpStrength: 5,
waveEnabled: false,
waveSpeed: 1,
waveAmount: 2,
},
});
@@ -0,0 +1,110 @@
import {
normalizeHalftoneStudioSettings,
type HalftoneExportPose,
type HalftoneImageFit,
type HalftoneStudioSettings,
} from '@/lib/halftone';
export const PROMO_MIC_IMAGE_URL =
'/illustrations/generated/partner-meeting.webp';
export const PROMO_MIC_IMAGE_FIT: HalftoneImageFit = 'cover';
export const PROMO_MIC_PREVIEW_DISTANCE = 4;
export const PROMO_MIC_INITIAL_POSE: HalftoneExportPose = {
autoElapsed: 0,
rotateElapsed: 0,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
targetRotationX: 0,
targetRotationY: 0,
timeElapsed: 86.56079999985694,
};
export const PROMO_MIC_SETTINGS: HalftoneStudioSettings =
normalizeHalftoneStudioSettings({
sourceMode: 'image',
shapeKey: 'torusKnot',
lighting: {
intensity: 1.5,
fillIntensity: 0.15,
ambientIntensity: 0.08,
angleDegrees: 45,
height: 2,
},
material: {
surface: 'solid',
color: '#d4d0c8',
roughness: 0.42,
metalness: 0.16,
thickness: 150,
refraction: 2,
environmentPower: 5,
},
halftone: {
enabled: true,
scale: 16,
power: -0.07,
toneTarget: 'dark',
width: 0.46,
imageContrast: 1,
dashColor: '#777',
hoverDashColor: '#4A38F5',
},
background: {
transparent: true,
color: '#f3f3f3',
},
animation: {
autoRotateEnabled: true,
breatheEnabled: false,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: false,
floatEnabled: false,
hoverHalftoneEnabled: false,
hoverLightEnabled: true,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.2,
autoWobble: 0.3,
breatheAmount: 0.04,
breatheSpeed: 0.8,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 0.2,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepRange: 28,
lightSweepSpeed: 0.7,
springDamping: 0.72,
springReturnEnabled: false,
springStrength: 0.18,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.2,
hoverHalftoneWidthShift: -0.18,
hoverLightIntensity: 1.2,
hoverLightRadius: 0.32,
dragFlowDecay: 0.08,
dragFlowRadius: 0.24,
dragFlowStrength: 1.8,
hoverWarpStrength: 3,
hoverWarpRadius: 0.15,
dragWarpStrength: 5,
waveEnabled: false,
waveSpeed: 1,
waveAmount: 2,
},
});
@@ -1,16 +0,0 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
const CTAsContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(4)};
justify-content: center;
`;
type CtaProps = { children: ReactNode };
export function Cta({ children }: CtaProps) {
return <CTAsContainer>{children}</CTAsContainer>;
}
@@ -1,20 +0,0 @@
import { Heading as BaseHeading } from '@/design-system/components';
import type { HeadingType } from '@/design-system/components/Heading';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const StyledHeading = styled(BaseHeading)`
max-width: 360px;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 672px;
}
`;
type HeadingProps = {
segments: HeadingType[];
};
export function Heading({ segments }: HeadingProps) {
return <StyledHeading segments={segments} size="lg" weight="light" />;
}
@@ -1,89 +0,0 @@
import { Container } from '@/design-system/components';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import type { ReactNode } from 'react';
const RootWrapper = styled.div`
display: grid;
grid-template-columns: 1fr;
position: relative;
width: 100%;
`;
const BackgroundLayer = styled.div`
align-self: stretch;
grid-column: 1;
grid-row: 1;
opacity: 0.6;
pointer-events: none;
position: relative;
width: 100%;
z-index: 0;
`;
const PatternLayer = styled.div`
bottom: 0;
height: 60%;
left: 50%;
position: absolute;
transform: translateX(-50%);
width: 100%;
`;
const patternImageClassName = css`
object-fit: cover;
object-position: center top;
`;
const StyledSection = styled.section`
grid-column: 1;
grid-row: 1;
position: relative;
width: 100%;
z-index: 1;
`;
const StyledContainer = styled(Container)`
display: grid;
grid-template-columns: 1fr;
justify-items: center;
padding-bottom: ${theme.spacing(16)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(16)};
row-gap: ${theme.spacing(6)};
text-align: center;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
padding-bottom: ${theme.spacing(24)};
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
padding-top: ${theme.spacing(24)};
}
`;
type RootProps = { children: ReactNode };
export function Root({ children }: RootProps) {
return (
<RootWrapper>
<BackgroundLayer aria-hidden>
<PatternLayer>
<NextImage
alt=""
className={patternImageClassName}
fill
sizes="(min-width: 921px) 100vw"
src="/images/product/demo/background.webp"
/>
</PatternLayer>
</BackgroundLayer>
<StyledSection>
<StyledContainer>{children}</StyledContainer>
</StyledSection>
</RootWrapper>
);
}
@@ -1,42 +0,0 @@
import type { ImageType } from '@/design-system/components/Image';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
const ScreenshotRoot = styled.div`
aspect-ratio: 136 / 69;
border-radius: ${theme.radius(4)};
overflow: hidden;
position: relative;
width: 100%;
max-width: 800px;
margin-top: ${theme.spacing(12)};
margin-bottom: ${theme.spacing(11)};
@media (min-width: ${theme.breakpoints.md}px) {
border-radius: ${theme.radius(6)};
margin-top: ${theme.spacing(19)};
margin-bottom: ${theme.spacing(11)};
}
`;
const screenshotImageClassName = css`
object-fit: cover;
`;
type ScreenshotProps = { image: ImageType };
export function Screenshot({ image }: ScreenshotProps) {
return (
<ScreenshotRoot>
<NextImage
alt={image.alt}
className={screenshotImageClassName}
fill
sizes="(min-width: 1440px) 1360px, 100vw"
src={image.src}
/>
</ScreenshotRoot>
);
}
@@ -1,6 +0,0 @@
import { Cta } from './Cta';
import { Heading } from './Heading';
import { Root } from './Root';
import { Screenshot } from './Screenshot';
export const Demo = { Cta, Heading, Root, Screenshot };
@@ -1,9 +0,0 @@
import type { EyebrowType } from '@/design-system/components/Eyebrow';
import type { HeadingType } from '@/design-system/components/Heading';
import type { ImageType } from '@/design-system/components/Image';
export type DemoDataType = {
eyebrow: EyebrowType;
heading: HeadingType[];
image: ImageType;
};
@@ -1 +0,0 @@
export type { DemoDataType } from './DemoData';
@@ -1 +0,0 @@
export type { EditorialDataType } from './EditorialData';
@@ -7,9 +7,13 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { createAnimationFrameLoop } from '@/lib/animation';
import { observeElementSize } from '@/lib/dom/observe-element-size';
import {
createFrameTimer,
createSiteWebGlRenderer,
createVisualRenderLoop,
tryCreateSiteWebGlRenderer,
type VisualRenderLoop,
type VisualRenderLoopFrame,
} from '@/lib/visual-runtime';
import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path';
@@ -606,22 +610,25 @@ export function FaqBackground() {
shell.style.bottom = 'auto';
shell.style.height = `${initialHeight}px`;
let resizeFrameId = 0;
const handleWindowResize = () => {
window.cancelAnimationFrame(resizeFrameId);
resizeFrameId = window.requestAnimationFrame(() => {
const resizeTask = createAnimationFrameLoop({
onFrame: () => {
const currentViewportHeight =
window.innerHeight || initialViewportHeight;
const ratio = currentViewportHeight / initialViewportHeight;
shell.style.height = `${Math.round(initialHeight * ratio)}px`;
});
return false;
},
});
const handleWindowResize = () => {
resizeTask.stop();
resizeTask.start();
};
window.addEventListener('resize', handleWindowResize);
return () => {
window.cancelAnimationFrame(resizeFrameId);
resizeTask.stop();
window.removeEventListener('resize', handleWindowResize);
};
}, []);
@@ -634,7 +641,7 @@ export function FaqBackground() {
}
let cancelled = false;
let animationFrameId = 0;
let renderLoop: VisualRenderLoop | null = null;
let rotateElapsed = INITIAL_ROTATE_ELAPSED;
const getWidth = () => Math.max(container.clientWidth, 1);
@@ -648,7 +655,18 @@ export function FaqBackground() {
1,
);
const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true });
const renderer = tryCreateSiteWebGlRenderer({
antialias: false,
alpha: true,
onContextLost: () => {
renderLoop?.stop();
},
});
if (renderer === null) {
return;
}
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
@@ -807,8 +825,7 @@ export function FaqBackground() {
};
applySize();
const resizeObserver = new ResizeObserver(applySize);
resizeObserver.observe(container);
const stopObservingSize = observeElementSize(container, applySize);
loadFaqGeometry(GLB_URL)
.then((geometry) => {
@@ -825,17 +842,16 @@ export function FaqBackground() {
console.error(error);
});
const frameTimer = createFrameTimer();
const renderFrame = () => {
const renderFrame = (
_timestamp: DOMHighResTimeStamp,
{ elapsedSeconds }: VisualRenderLoopFrame,
) => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = 1 / 60;
const elapsedTime = INITIAL_TIME_ELAPSED + frameTimer.getElapsed();
const elapsedTime = INITIAL_TIME_ELAPSED + elapsedSeconds;
halftoneMaterial.uniforms.time.value = elapsedTime;
rotateElapsed += delta;
@@ -907,12 +923,18 @@ export function FaqBackground() {
renderer.render(postScene, orthographicCamera);
};
renderFrame();
renderLoop = createVisualRenderLoop({
renderFrame,
shouldRender: () => !cancelled,
target: container,
targetVisibilityOptions: { rootMargin: '100px' },
});
renderLoop.start();
return () => {
cancelled = true;
resizeObserver.disconnect();
window.cancelAnimationFrame(animationFrameId);
renderLoop?.dispose();
stopObservingSize();
blurHorizontalMaterial.dispose();
blurVerticalMaterial.dispose();
halftoneMaterial.dispose();
@@ -6,9 +6,12 @@ import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { observeElementSize } from '@/lib/dom/observe-element-size';
import {
createFrameTimer,
createSiteWebGlRenderer,
createVisualRenderLoop,
tryCreateSiteWebGlRenderer,
type VisualRenderLoop,
type VisualRenderLoopFrame,
} from '@/lib/visual-runtime';
import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path';
@@ -627,7 +630,7 @@ export function FooterBackground() {
}
let cancelled = false;
let animationFrameId = 0;
let renderLoop: VisualRenderLoop | null = null;
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
@@ -640,7 +643,18 @@ export function FooterBackground() {
1,
);
const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true });
const renderer = tryCreateSiteWebGlRenderer({
antialias: false,
alpha: true,
onContextLost: () => {
renderLoop?.stop();
},
});
if (renderer === null) {
return;
}
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
@@ -802,8 +816,7 @@ export function FooterBackground() {
);
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
const stopObservingSize = observeElementSize(container, syncSize);
const updatePointerPosition = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
@@ -894,16 +907,15 @@ export function FooterBackground() {
console.error(error);
});
const frameTimer = createFrameTimer();
const renderFrame = () => {
const renderFrame = (
_timestamp: DOMHighResTimeStamp,
{ elapsedSeconds }: VisualRenderLoopFrame,
) => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const elapsedTime = INITIAL_TIME_ELAPSED + frameTimer.getElapsed();
const elapsedTime = INITIAL_TIME_ELAPSED + elapsedSeconds;
halftoneMaterial.uniforms.time.value = elapsedTime;
halftoneMaterial.uniforms.interactionUv.value.set(
interaction.mouseX,
@@ -965,18 +977,24 @@ export function FooterBackground() {
renderer.render(postScene, orthographicCamera);
};
renderFrame();
renderLoop = createVisualRenderLoop({
renderFrame,
shouldRender: () => !cancelled,
target: container,
targetVisibilityOptions: { rootMargin: '100px' },
});
renderLoop.start();
return () => {
cancelled = true;
resizeObserver.disconnect();
renderLoop?.dispose();
stopObservingSize();
canvas.removeEventListener('pointermove', handlePointerMove);
canvas.removeEventListener('pointerleave', handlePointerLeave);
canvas.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointermove', handleWindowPointerMove);
window.removeEventListener('blur', handleWindowBlur);
window.cancelAnimationFrame(animationFrameId);
blurHorizontalMaterial.dispose();
blurVerticalMaterial.dispose();
halftoneMaterial.dispose();
@@ -1,2 +0,0 @@
export type { HeadingCardType, HelpedVisualId } from './HeadingCard';
export type { HelpedDataType } from './HelpedData';
@@ -19,9 +19,12 @@ import * as THREE from 'three';
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { observeElementSize } from '@/lib/dom/observe-element-size';
import {
createFrameTimer,
createSiteWebGlRenderer,
createVisualRenderLoop,
tryCreateSiteWebGlRenderer,
type VisualRenderLoop,
type VisualRenderLoopFrame,
} from '@/lib/visual-runtime';
import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path';
@@ -827,7 +830,7 @@ function HelpedHalftoneCanvas({
return;
}
let animationFrameId = 0;
let renderLoop: VisualRenderLoop | null = null;
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
@@ -840,7 +843,18 @@ function HelpedHalftoneCanvas({
1,
);
const renderer = createSiteWebGlRenderer({ antialias: false, alpha: true });
const renderer = tryCreateSiteWebGlRenderer({
antialias: false,
alpha: true,
onContextLost: () => {
renderLoop?.stop();
},
});
if (renderer === null) {
return;
}
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
@@ -1026,8 +1040,7 @@ function HelpedHalftoneCanvas({
);
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
const stopObservingSize = observeElementSize(container, syncSize);
const updatePointerPosition = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
@@ -1128,13 +1141,12 @@ function HelpedHalftoneCanvas({
window.addEventListener('pointermove', handleWindowPointerMove);
window.addEventListener('pointerup', handlePointerUp);
const frameTimer = createFrameTimer();
const renderFrame = () => {
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = frameTimer.tick();
const elapsedTime = initialPose.timeElapsed + frameTimer.getElapsed();
const renderFrame = (
_timestamp: DOMHighResTimeStamp,
{ deltaSeconds, elapsedSeconds }: VisualRenderLoopFrame,
) => {
const delta = deltaSeconds;
const elapsedTime = initialPose.timeElapsed + elapsedSeconds;
halftoneMaterial.uniforms.time.value = elapsedTime;
let baseRotationX = 0;
@@ -1378,11 +1390,16 @@ function HelpedHalftoneCanvas({
renderer.render(postScene, orthographicCamera);
};
renderFrame();
renderLoop = createVisualRenderLoop({
renderFrame,
target: container,
targetVisibilityOptions: { rootMargin: '100px' },
});
renderLoop.start();
return () => {
window.cancelAnimationFrame(animationFrameId);
resizeObserver.disconnect();
renderLoop?.dispose();
stopObservingSize();
canvas.removeEventListener('pointerdown', handlePointerDown);
canvas.removeEventListener('pointerleave', handlePointerLeave);
canvas.removeEventListener('pointermove', handlePointerMove);
@@ -4,7 +4,7 @@ import type {
HeroSidebarItem,
HeroTablePageDefinition,
HeroWorkflowPageDefinition,
} from '../../types/HeroHomeData';
} from '@/sections/Hero/types';
export type HeroPageDefaults = {
defaultActions: string[];
@@ -1,4 +1,4 @@
import type { HeroNavbarActionType } from '../../types/HeroHomeData';
import type { HeroNavbarActionType } from '@/sections/Hero/types';
type PinnedAction = HeroNavbarActionType;
@@ -1,7 +1,7 @@
import type {
HeroSidebarItem,
HeroTablePageDefinition,
} from '../../types/HeroHomeData';
} from '@/sections/Hero/types';
export const ROCKET_ITEM_ID = 'rockets';
export const ROCKET_ITEM_LABEL = 'Rockets';
@@ -3,17 +3,20 @@
import { styled } from '@linaria/react';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
import { theme } from '@/theme';
import { VISUAL_TOKENS } from '../homeVisualTokens';
import { VISUAL_TOKENS } from '../Shared/homeVisualTokens';
import { useWindowPointerInteractions } from '../WindowInteraction/use-window-pointer-interactions';
import type {
WindowPosition as Position,
WindowSize as Size,
} from '../WindowInteraction/window-geometry';
import { useWindowOrder } from '../WindowOrder/WindowOrderProvider';
import { WINDOW_SHADOWS } from '../windowShadows';
import { WINDOW_SHADOWS } from '../Shared/windowShadows';
import { MacWindowBar } from './MacWindowBar';
const WINDOW_ID = 'twenty-app-window';
@@ -24,61 +27,6 @@ const INITIAL_MAX_WIDTH = 1040;
const INITIAL_ASPECT_RATIO = 1280 / 832;
const MOBILE_PARENT_BREAKPOINT = 640;
type Position = { left: number; top: number };
type Size = { width: number; height: number };
type DragState = {
pointerId: number;
originX: number;
originY: number;
startLeft: number;
startTop: number;
};
type ResizeCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
type ResizeEdge = 'top' | 'right' | 'bottom' | 'left';
type ResizeHandle = ResizeCorner | ResizeEdge;
type ResizeState = {
pointerId: number;
originX: number;
originY: number;
startWidth: number;
startHeight: number;
startLeft: number;
startTop: number;
handle: ResizeHandle;
};
const HORIZONTAL_HANDLES: ReadonlySet<ResizeHandle> = new Set([
'top-left',
'top-right',
'bottom-left',
'bottom-right',
'left',
'right',
]);
const VERTICAL_HANDLES: ReadonlySet<ResizeHandle> = new Set([
'top-left',
'top-right',
'bottom-left',
'bottom-right',
'top',
'bottom',
]);
const LEFT_HANDLES: ReadonlySet<ResizeHandle> = new Set([
'top-left',
'bottom-left',
'left',
]);
const TOP_HANDLES: ReadonlySet<ResizeHandle> = new Set([
'top-left',
'top-right',
'top',
]);
const Shell = styled.div<{
$isResizing: boolean;
$isReady: boolean;
@@ -182,13 +130,9 @@ type DraggableAppWindowProps = {
export const DraggableAppWindow = ({ children }: DraggableAppWindowProps) => {
const shellRef = useRef<HTMLDivElement>(null);
const dragStateRef = useRef<DragState | null>(null);
const resizeStateRef = useRef<ResizeState | null>(null);
const [position, setPosition] = useState<Position | null>(null);
const [size, setSize] = useState<Size | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const { activate, zIndex } = useWindowOrder(WINDOW_ID);
@@ -229,247 +173,26 @@ export const DraggableAppWindow = ({ children }: DraggableAppWindowProps) => {
return parent?.getBoundingClientRect() ?? null;
}, []);
const clampPosition = useCallback(
(candidateLeft: number, candidateTop: number, currentSize: Size) => {
const parentRect = getParentRect();
if (!parentRect) {
return { left: candidateLeft, top: candidateTop };
}
const maxLeft = parentRect.width - currentSize.width - MIN_EDGE_GAP;
const maxTop = parentRect.height - currentSize.height - MIN_EDGE_GAP;
return {
left: Math.min(Math.max(candidateLeft, MIN_EDGE_GAP), maxLeft),
top: Math.min(Math.max(candidateTop, MIN_EDGE_GAP), maxTop),
};
},
[getParentRect],
);
const handleDragStart = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
const target = event.target as HTMLElement | null;
if (
target &&
target.closest('button, a, input, textarea, select, [role="button"]')
) {
return;
}
if (!position) {
return;
}
event.preventDefault();
activate();
const shell = shellRef.current;
shell?.setPointerCapture?.(event.pointerId);
dragStateRef.current = {
pointerId: event.pointerId,
originX: event.clientX,
originY: event.clientY,
startLeft: position.left,
startTop: position.top,
};
setIsDragging(true);
},
[activate, position],
);
const latestPositionRef = useRef<Position | null>(position);
const latestSizeRef = useRef<Size | null>(size);
useEffect(() => {
latestPositionRef.current = position;
}, [position]);
useEffect(() => {
latestSizeRef.current = size;
}, [size]);
useEffect(() => {
if (!isDragging) {
return undefined;
}
const handleMove = (event: PointerEvent) => {
const state = dragStateRef.current;
if (!state || state.pointerId !== event.pointerId || !size) {
return;
}
const nextLeft = state.startLeft + (event.clientX - state.originX);
const nextTop = state.startTop + (event.clientY - state.originY);
const clamped = clampPosition(nextLeft, nextTop, size);
latestPositionRef.current = clamped;
const shell = shellRef.current;
if (shell !== null) {
shell.style.transform = `translate3d(${clamped.left}px, ${clamped.top}px, 0)`;
}
};
const stop = (event: PointerEvent) => {
const state = dragStateRef.current;
if (!state || state.pointerId !== event.pointerId) {
return;
}
dragStateRef.current = null;
setIsDragging(false);
const committed = latestPositionRef.current;
if (committed !== null) {
setPosition(committed);
}
shellRef.current?.releasePointerCapture?.(event.pointerId);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
};
}, [clampPosition, isDragging, size]);
const startResize = useCallback(
(handle: ResizeHandle) => (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
if (!position || !size) {
return;
}
event.preventDefault();
event.stopPropagation();
activate();
const shell = shellRef.current;
shell?.setPointerCapture?.(event.pointerId);
resizeStateRef.current = {
pointerId: event.pointerId,
originX: event.clientX,
originY: event.clientY,
startWidth: size.width,
startHeight: size.height,
startLeft: position.left,
startTop: position.top,
handle,
};
setIsResizing(true);
},
[activate, position, size],
);
useEffect(() => {
if (!isResizing) {
return undefined;
}
const handleMove = (event: PointerEvent) => {
const state = resizeStateRef.current;
if (!state || state.pointerId !== event.pointerId) {
return;
}
const parentRect = getParentRect();
if (!parentRect) {
return;
}
const deltaX = event.clientX - state.originX;
const deltaY = event.clientY - state.originY;
const affectsWidth = HORIZONTAL_HANDLES.has(state.handle);
const affectsHeight = VERTICAL_HANDLES.has(state.handle);
const growsFromLeft = LEFT_HANDLES.has(state.handle);
const growsFromTop = TOP_HANDLES.has(state.handle);
const effectiveMinWidth = Math.min(
MIN_WIDTH,
Math.max(parentRect.width - MIN_EDGE_GAP * 2, 0),
);
const effectiveMinHeight = Math.min(
MIN_HEIGHT,
Math.max(parentRect.height - MIN_EDGE_GAP * 2, 0),
);
let nextWidth = state.startWidth;
let nextLeft = state.startLeft;
if (affectsWidth) {
if (growsFromLeft) {
const maxWidth = state.startWidth + state.startLeft - MIN_EDGE_GAP;
nextWidth = Math.min(
Math.max(state.startWidth - deltaX, effectiveMinWidth),
Math.max(maxWidth, effectiveMinWidth),
);
nextLeft = state.startLeft + state.startWidth - nextWidth;
} else {
const maxWidth = parentRect.width - state.startLeft - MIN_EDGE_GAP;
nextWidth = Math.min(
Math.max(state.startWidth + deltaX, effectiveMinWidth),
Math.max(maxWidth, effectiveMinWidth),
);
}
}
let nextHeight = state.startHeight;
let nextTop = state.startTop;
if (affectsHeight) {
if (growsFromTop) {
const maxHeight = state.startHeight + state.startTop - MIN_EDGE_GAP;
nextHeight = Math.min(
Math.max(state.startHeight - deltaY, effectiveMinHeight),
Math.max(maxHeight, effectiveMinHeight),
);
nextTop = state.startTop + state.startHeight - nextHeight;
} else {
const maxHeight = parentRect.height - state.startTop - MIN_EDGE_GAP;
nextHeight = Math.min(
Math.max(state.startHeight + deltaY, effectiveMinHeight),
Math.max(maxHeight, effectiveMinHeight),
);
}
}
latestSizeRef.current = { width: nextWidth, height: nextHeight };
latestPositionRef.current = { left: nextLeft, top: nextTop };
const shell = shellRef.current;
if (shell !== null) {
shell.style.width = `${nextWidth}px`;
shell.style.height = `${nextHeight}px`;
shell.style.transform = `translate3d(${nextLeft}px, ${nextTop}px, 0)`;
}
};
const stop = (event: PointerEvent) => {
const state = resizeStateRef.current;
if (!state || state.pointerId !== event.pointerId) {
return;
}
resizeStateRef.current = null;
setIsResizing(false);
const committedSize = latestSizeRef.current;
const committedPosition = latestPositionRef.current;
if (committedSize !== null) {
setSize(committedSize);
}
if (committedPosition !== null) {
setPosition(committedPosition);
}
shellRef.current?.releasePointerCapture?.(event.pointerId);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
};
}, [getParentRect, isResizing]);
const {
handleDragStart,
isDragging,
isResizing,
latestPositionRef,
latestSizeRef,
startResize,
} = useWindowPointerInteractions({
activate,
blockedDragTargetSelector:
'button, a, input, textarea, select, [role="button"]',
edgeGap: MIN_EDGE_GAP,
getBounds: getParentRect,
minSize: { width: MIN_WIDTH, height: MIN_HEIGHT },
position,
setPosition,
setSize,
shellRef,
size,
});
const handleShellPointerDown = useCallback(() => {
activate();
@@ -2,7 +2,7 @@
import { styled } from '@linaria/react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { TerminalTrafficLights } from '../DraggableTerminal/TerminalTrafficLights';
import { TerminalTrafficLights } from '../DraggableTerminal/TerminalTrafficLights/TerminalTrafficLights';
type MacWindowBarProps = {
title?: string;
@@ -0,0 +1,89 @@
'use client';
import { styled } from '@linaria/react';
import { useMemo } from 'react';
import {
ASSISTANT_RESPONSE_STREAMING_STAGES,
buildAssistantResponseSegments,
} from './assistant-response-segments';
import { AssistantResponseParagraph } from './AssistantResponseParagraph';
import { ChangesSummaryCard } from './ChangesSummaryCard';
import { ThinkingIndicator } from './ThinkingIndicator';
import { useAssistantResponseStage } from './use-assistant-response-stage';
const ResponseRoot = styled.div`
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
`;
const CardWrap = styled.div<{ $instant: boolean }>`
animation: ${({ $instant }) =>
$instant
? 'none'
: 'chatCardRise 420ms cubic-bezier(0.22, 1, 0.36, 1) both'};
@keyframes chatCardRise {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
type AssistantResponseProps = {
instantComplete?: boolean;
onUndo?: () => void;
onObjectCreated?: (id: string) => void;
onChatFinished?: () => void;
};
export const AssistantResponse = ({
instantComplete = false,
onUndo,
onObjectCreated,
onChatFinished,
}: AssistantResponseProps) => {
const { createStageCompletionHandler, hasReachedStage, stage } =
useAssistantResponseStage({
instantComplete,
onChatFinished,
});
const objectCreationHandler = instantComplete ? undefined : onObjectCreated;
const segmentsByStage = useMemo(
() => buildAssistantResponseSegments(objectCreationHandler),
[objectCreationHandler],
);
return (
<ResponseRoot>
{stage === 'thinking' && <ThinkingIndicator />}
{ASSISTANT_RESPONSE_STREAMING_STAGES.map((responseStage) =>
hasReachedStage(responseStage) ? (
<AssistantResponseParagraph
key={responseStage}
activeStage={stage}
instant={instantComplete}
onStageComplete={createStageCompletionHandler}
segments={segmentsByStage[responseStage]}
stage={responseStage}
/>
) : null,
)}
{hasReachedStage('card') && (
<CardWrap $instant={instantComplete}>
<ChangesSummaryCard onUndo={onUndo} />
</CardWrap>
)}
</ResponseRoot>
);
};
@@ -0,0 +1,42 @@
import { styled } from '@linaria/react';
import { TERMINAL_TOKENS } from '../terminalTokens';
import {
type AssistantResponseStage,
type AssistantResponseStreamingStage,
} from './assistant-response-stage';
import { CHAT_TIMINGS } from './animationTiming';
import { StreamingText, type StreamingSegment } from './StreamingText';
const Paragraph = styled.p`
color: ${TERMINAL_TOKENS.text.prompt};
font-family: ${TERMINAL_TOKENS.font.ui};
font-size: 13px;
line-height: 20px;
margin: 0;
`;
type AssistantResponseParagraphProps = {
activeStage: AssistantResponseStage;
instant: boolean;
onStageComplete: (stage: AssistantResponseStreamingStage) => () => void;
segments: StreamingSegment[];
stage: AssistantResponseStreamingStage;
};
export const AssistantResponseParagraph = ({
activeStage,
instant,
onStageComplete,
segments,
stage,
}: AssistantResponseParagraphProps) => (
<Paragraph>
<StreamingText
charDurationMs={CHAT_TIMINGS.textStreamCharMs}
instant={instant}
onComplete={activeStage === stage ? onStageComplete(stage) : undefined}
segments={segments}
/>
</Paragraph>
);
@@ -4,20 +4,19 @@ import { styled } from '@linaria/react';
import {
IconArrowBackUp,
IconChevronDown,
IconChevronRight,
IconChevronUp,
} from '@tabler/icons-react';
import { useState } from 'react';
import { CHAT_TIMINGS } from './animationTiming';
import {
CHANGESET_TOTALS,
ROCKET_CHANGESET,
type FileChange,
} from './rocketChangeset';
const ROW_STAGGER_MS = 24;
const ROW_BASE_DELAY_MS = 40;
const COLLAPSED_FILE_COUNT = 3;
getChangesSummaryRowDelay,
getHiddenChangesCount,
getVisibleChanges,
} from './changes-summary-card-state';
import { DiffAdded, DiffRemoved } from './ChangesSummaryDiffCounts';
import { ChangesSummaryFileRow } from './ChangesSummaryFileRow';
import { CHANGESET_TOTALS, ROCKET_CHANGESET } from './rocketChangeset';
const CardRoot = styled.div`
animation: chatCardRise ${CHAT_TIMINGS.fileCardEnterMs}ms
@@ -60,18 +59,6 @@ const HeaderTitle = styled.div`
gap: 6px;
`;
const DiffAdded = styled.span`
color: #2f7d52;
font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
font-weight: 600;
`;
const DiffRemoved = styled.span`
color: #a94a4f;
font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
font-weight: 600;
`;
const UndoButton = styled.button`
align-items: center;
background: transparent;
@@ -101,82 +88,6 @@ const FileList = styled.div`
flex-direction: column;
`;
const FileRow = styled.div<{ $delay: string }>`
animation: chatFileRowFade 240ms ease-out both;
animation-delay: ${({ $delay }) => $delay};
align-items: center;
display: flex;
gap: 10px;
padding: 9px 14px;
transition: background-color 0.14s ease;
& + & {
border-top: 1px solid rgba(0, 0, 0, 0.04);
}
&:hover {
background: rgba(0, 0, 0, 0.02);
}
@keyframes chatFileRowFade {
from {
opacity: 0;
transform: translateY(3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const FilePath = styled.span`
color: rgba(0, 0, 0, 0.78);
flex: 1 1 auto;
font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 11.5px;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const DiffCounts = styled.span`
align-items: center;
display: inline-flex;
flex: 0 0 auto;
font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 11px;
gap: 6px;
`;
const Chevron = styled.span`
align-items: center;
color: rgba(0, 0, 0, 0.3);
display: inline-flex;
flex: 0 0 auto;
`;
const ZeroCount = styled.span`
color: rgba(0, 0, 0, 0.35);
`;
const renderDiffCounts = (change: FileChange) => (
<DiffCounts>
{change.added > 0 ? (
<DiffAdded>+{change.added}</DiffAdded>
) : (
<ZeroCount>+0</ZeroCount>
)}
{change.removed > 0 ? (
<DiffRemoved>-{change.removed}</DiffRemoved>
) : (
<ZeroCount>-0</ZeroCount>
)}
</DiffCounts>
);
const SeeMoreButton = styled.button`
align-items: center;
background: transparent;
@@ -213,14 +124,11 @@ type ChangesSummaryCardProps = {
export const ChangesSummaryCard = ({ onUndo }: ChangesSummaryCardProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const hiddenCount = Math.max(
ROCKET_CHANGESET.length - COLLAPSED_FILE_COUNT,
0,
);
const visibleChanges =
isExpanded || hiddenCount === 0
? ROCKET_CHANGESET
: ROCKET_CHANGESET.slice(0, COLLAPSED_FILE_COUNT);
const hiddenCount = getHiddenChangesCount({ changes: ROCKET_CHANGESET });
const visibleChanges = getVisibleChanges({
changes: ROCKET_CHANGESET,
isExpanded,
});
return (
<CardRoot>
@@ -237,16 +145,11 @@ export const ChangesSummaryCard = ({ onUndo }: ChangesSummaryCardProps) => {
</Header>
<FileList>
{visibleChanges.map((change, index) => (
<FileRow
$delay={`${ROW_BASE_DELAY_MS + index * ROW_STAGGER_MS}ms`}
<ChangesSummaryFileRow
animationDelay={getChangesSummaryRowDelay(index)}
change={change}
key={`${change.path}-${index}`}
>
<FilePath>{change.path}</FilePath>
{renderDiffCounts(change)}
<Chevron>
<IconChevronRight size={14} stroke={1.8} />
</Chevron>
</FileRow>
/>
))}
</FileList>
{hiddenCount > 0 && (
@@ -0,0 +1,45 @@
import { styled } from '@linaria/react';
export const DiffAdded = styled.span`
color: #2f7d52;
font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
font-weight: 600;
`;
export const DiffRemoved = styled.span`
color: #a94a4f;
font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
font-weight: 600;
`;
const DiffCounts = styled.span`
align-items: center;
display: inline-flex;
flex: 0 0 auto;
font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 11px;
gap: 6px;
`;
const ZeroCount = styled.span`
color: rgba(0, 0, 0, 0.35);
`;
type ChangesSummaryDiffCountsProps = {
added: number;
removed: number;
};
export const ChangesSummaryDiffCounts = ({
added,
removed,
}: ChangesSummaryDiffCountsProps) => (
<DiffCounts>
{added > 0 ? <DiffAdded>+{added}</DiffAdded> : <ZeroCount>+0</ZeroCount>}
{removed > 0 ? (
<DiffRemoved>-{removed}</DiffRemoved>
) : (
<ZeroCount>-0</ZeroCount>
)}
</DiffCounts>
);
@@ -0,0 +1,71 @@
import { styled } from '@linaria/react';
import { IconChevronRight } from '@tabler/icons-react';
import type { FileChange } from './rocketChangeset';
import { ChangesSummaryDiffCounts } from './ChangesSummaryDiffCounts';
const FileRow = styled.div<{ $delay: string }>`
animation: chatFileRowFade 240ms ease-out both;
animation-delay: ${({ $delay }) => $delay};
align-items: center;
display: flex;
gap: 10px;
padding: 9px 14px;
transition: background-color 0.14s ease;
& + & {
border-top: 1px solid rgba(0, 0, 0, 0.04);
}
&:hover {
background: rgba(0, 0, 0, 0.02);
}
@keyframes chatFileRowFade {
from {
opacity: 0;
transform: translateY(3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const FilePath = styled.span`
color: rgba(0, 0, 0, 0.78);
flex: 1 1 auto;
font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 11.5px;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const Chevron = styled.span`
align-items: center;
color: rgba(0, 0, 0, 0.3);
display: inline-flex;
flex: 0 0 auto;
`;
type ChangesSummaryFileRowProps = {
animationDelay: string;
change: FileChange;
};
export const ChangesSummaryFileRow = ({
animationDelay,
change,
}: ChangesSummaryFileRowProps) => (
<FileRow $delay={animationDelay}>
<FilePath>{change.path}</FilePath>
<ChangesSummaryDiffCounts added={change.added} removed={change.removed} />
<Chevron>
<IconChevronRight size={14} stroke={1.8} />
</Chevron>
</FileRow>
);

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