From 979e49f44902cbc9d8b137fbe0e87a6515941255 Mon Sep 17 00:00:00 2001 From: andresdjasso Date: Sat, 5 Sep 2026 16:53:03 -0700 Subject: [PATCH 1/2] feat(landing): deliver phase one marketing redesign --- .../app/(auth)/components/auth-shell.test.tsx | 24 + apps/sim/app/(auth)/components/auth-shell.tsx | 7 +- .../app/(auth)/signup/signup-form.test.tsx | 132 +++ apps/sim/app/(auth)/signup/signup-form.tsx | 8 +- apps/sim/app/(landing)/CLAUDE.md | 10 +- apps/sim/app/(landing)/careers/careers.tsx | 8 +- .../(landing)/comparisons/[provider]/page.tsx | 5 - .../agent-momentum/agent-momentum-metrics.tsx | 149 ++++ .../agent-momentum/agent-momentum.test.tsx | 36 + .../agent-momentum/agent-momentum.tsx | 61 ++ .../components/agent-momentum/index.ts | 1 + .../chevron-arrow/chevron-arrow.tsx | 29 +- .../content-author-loading.tsx | 4 +- .../content-author-page.tsx | 9 +- .../content-index-loading.tsx | 4 +- .../content-index-page/content-index-page.tsx | 9 +- .../content-post-loading.tsx | 12 +- .../content-post-page/content-post-page.tsx | 12 +- .../(landing)/components/cta/cta.module.css | 36 + apps/sim/app/(landing)/components/cta/cta.tsx | 84 +- .../featured-customer-card.tsx | 118 +++ .../featured-customer-navigation-button.tsx | 124 +++ .../featured-customer.test.tsx | 248 ++++++ .../featured-customer/featured-customer.tsx | 157 ++++ .../components/featured-customer/index.ts | 1 + .../build-chat-animation.tsx | 17 +- .../core-feature-card/core-feature-card.tsx | 73 ++ .../components/core-feature-card/index.ts | 1 + .../components/feature-card/feature-card.tsx | 8 +- .../features-rail/features-rail.test.tsx | 193 ++++ .../features-rail/features-rail.tsx | 243 +++++ .../components/features-rail/index.ts | 1 + .../integrations-callout.tsx | 15 +- .../components/features/features.test.tsx | 31 + .../components/features/features.tsx | 173 ++-- .../footer-wordmark-loop.test.tsx | 128 +++ .../footer-wordmark-loop.tsx | 637 ++++++++++++++ .../components/footer-wordmark-loop/index.ts | 1 + .../footer/components/theme-toggle/index.ts | 1 + .../components/theme-toggle/theme-toggle.tsx | 90 ++ .../(landing)/components/footer/footer.tsx | 40 +- .../(landing)/components/govern/govern.tsx | 160 ++++ .../app/(landing)/components/govern/index.ts | 1 + .../components/hero-cta/hero-cta.tsx | 45 +- .../hero-announcement-chip.test.tsx | 52 ++ .../hero-announcement-chip.tsx | 48 + .../hero-announcement-chip/index.ts | 1 + .../hero-chat-loop/hero-chat-loop.test.tsx | 185 ++++ .../hero-chat-loop/hero-chat-loop.tsx | 496 +++++++---- .../hero-chat-welcome/hero-chat-welcome.tsx | 62 ++ .../components/hero-chat-welcome/index.ts | 1 + .../components/hero-header/hero-header.tsx | 66 +- .../draw-platform-preview.ts | 179 ++++ .../hero-platform-intro.test.tsx | 386 ++++++++ .../hero-platform-intro.tsx | 42 + .../components/hero-platform-intro/index.ts | 1 + .../hero-platform-intro/use-platform-intro.ts | 112 +++ .../hero-platform-loop.test.tsx | 324 +++++++ .../hero-platform-loop/hero-platform-loop.tsx | 398 +++++++-- .../hero-resource-panel.tsx | 281 ++++++ .../hero-workflow-stage.tsx | 100 ++- .../preview-build-timing.ts | 15 + .../preview-chat-content.ts | 15 + .../production-workflow-stage.module.css | 29 + .../production-workflow-stage.tsx | 788 +++++++++++++++++ .../hero-platform-loop/stage-block-card.tsx | 393 ++++++++- .../hero-platform-loop/stage-data.ts | 172 ++-- .../hero-platform-stage.tsx | 47 + .../components/hero-platform-stage/index.ts | 1 + .../components/hero-visual/hero-visual.tsx | 86 +- .../components/hero-visual/stage-home.tsx | 65 +- .../components/hero-visual/workflow-data.ts | 240 ++++- .../app/(landing)/components/hero/hero.tsx | 106 +-- .../home-structured-data.tsx | 14 +- apps/sim/app/(landing)/components/index.ts | 9 +- .../components => }/iso-marks/goo-defs.tsx | 0 .../(landing)/components/iso-marks/index.ts | 16 + .../iso-marks/iso-build-illustration.tsx | 0 .../iso-marks/iso-ingest-illustration.tsx | 0 .../iso-marks/iso-integrate-illustration.tsx | 0 .../iso-marks/iso-monitor-illustration.tsx | 0 .../components => }/iso-marks/use-goo-mark.ts | 0 .../components/landing-cta-link/index.ts | 1 + .../landing-cta-link/landing-cta-link.tsx | 54 ++ .../(landing)/components/landing-layout.ts | 112 ++- .../landing-shell/landing-shell.test.tsx | 217 +++++ .../landing-shell/landing-shell.tsx | 34 +- .../components/lifecycle/lifecycle.tsx | 6 +- .../components/logo-shell/logo-shell.tsx | 3 +- .../app/(landing)/components/logos/index.ts | 2 +- .../app/(landing)/components/logos/logos.tsx | 80 +- .../mothership/components/iso-marks/index.ts | 16 - .../components/mothership/mothership.tsx | 108 --- .../announcement-banner.tsx | 35 + .../components/announcement-banner/index.ts | 1 + .../components/github-chip/github-chip.tsx | 1 + .../components/navbar/components/index.ts | 6 +- .../components/mobile-nav/mobile-nav.test.tsx | 107 +++ .../components/mobile-nav/mobile-nav.tsx | 163 ++-- .../components/nav-menu-card/index.ts | 1 + .../nav-menu-card/nav-menu-card.tsx | 95 ++ .../nav-menu-item/nav-menu-item.tsx | 75 +- .../components/nav-menu-logo-marquee/index.ts | 1 + .../nav-menu-logo-marquee.module.css | 17 + .../nav-menu-logo-marquee.test.tsx | 50 ++ .../nav-menu-logo-marquee.tsx | 62 ++ .../blog-menu-preview/blog-menu-preview.tsx | 25 + .../components/blog-menu-preview/index.ts | 1 + .../changelog-menu-preview.tsx | 48 + .../changelog-menu-preview/index.ts | 1 + .../docs-menu-preview/docs-menu-preview.tsx | 48 + .../components/docs-menu-preview/index.ts | 1 + .../enterprise-menu-preview.tsx | 63 ++ .../enterprise-menu-preview/index.ts | 1 + .../files-menu-preview/files-menu-preview.tsx | 120 +++ .../components/files-menu-preview/index.ts | 1 + .../integrations-menu-preview/index.ts | 1 + .../integrations-menu-preview.tsx | 51 ++ .../knowledge-menu-preview/index.ts | 1 + .../knowledge-menu-preview.tsx | 124 +++ .../components/library-menu-preview/index.ts | 1 + .../library-menu-preview.tsx | 34 + .../logs-run-graph/logs-run-graph.tsx | 83 ++ .../components/logs-menu-preview/index.ts | 1 + .../logs-menu-preview/logs-menu-preview.tsx | 70 ++ .../components/menu-preview-frame/index.ts | 1 + .../menu-preview-frame/menu-preview-frame.tsx | 27 + .../menu-preview-header.tsx | 41 + .../components/models-menu-preview/index.ts | 1 + .../models-menu-preview.tsx | 41 + .../components/overview-menu-preview/index.ts | 1 + .../overview-menu-preview.module.css | 53 ++ .../overview-menu-preview.tsx | 29 + .../components/tables-menu-preview/index.ts | 1 + .../tables-menu-preview.tsx | 99 +++ .../components/workflow-menu-preview/index.ts | 1 + .../workflow-menu-preview.tsx | 76 ++ .../nav-menu-preview/nav-menu-preview.tsx | 114 +++ .../nav-menu-chip/constants.test.ts | 40 + .../components/nav-menu-chip/constants.ts | 329 +++++-- .../navbar/components/nav-menu-chip/index.ts | 4 +- .../nav-menu-chip/nav-menu-chip.test.tsx | 283 ++++++ .../nav-menu-chip/nav-menu-chip.tsx | 308 +++++-- .../navbar/components/nav-menu-chip/types.ts | 90 +- .../components/navbar-auth-pill/index.ts | 1 + .../navbar-auth-pill/navbar-auth-pill.tsx | 50 ++ .../navbar-shell/navbar-shell.test.tsx | 196 +++++ .../components/navbar-shell/navbar-shell.tsx | 96 +- .../components/sim-wordmark/sim-wordmark.tsx | 41 +- .../navbar/hooks/use-navbar-menu.ts | 34 + .../(landing)/components/navbar/navbar.tsx | 82 +- .../components/placement-frame/index.ts | 1 + .../placement-frame/placement-frame.tsx | 78 ++ .../build-agents-loop/build-agents-loop.tsx | 134 +++ .../components/build-agents-loop/index.ts | 1 + .../governance-loop/governance-loop.test.tsx | 70 ++ .../governance-loop/governance-loop.tsx | 242 +++++ .../components/governance-loop/index.ts | 1 + .../components/iso-marks/index.ts | 9 + .../iso-marks/iso-illustration-style.ts | 63 ++ .../iso-marks/iso-integrate-illustration.tsx | 136 +++ .../components/iso-marks/iso-marks.module.css | 31 + .../iso-marks/iso-monitor-illustration.tsx | 154 ++++ .../components/platform-suite/index.ts | 1 + .../platform-suite/platform-suite.test.tsx | 102 +++ .../platform-suite/platform-suite.tsx | 186 ++++ .../composer-loop/composer-loop.test.tsx | 123 +++ .../composer-loop/composer-loop.tsx | 833 ++++++++++++++++++ .../composer-loop/demo-composer.test.tsx | 82 ++ .../composer-loop/demo-composer.tsx | 159 ++++ .../composer-loop/demo-workflow-data.test.ts | 51 ++ .../composer-loop/demo-workflow-data.ts | 275 ++++++ .../components/composer-loop/index.ts | 1 + .../components/product-demo-caption/beats.ts | 13 + .../product-demo-caption/context.tsx | 28 + .../components/product-demo-caption/index.ts | 3 + .../product-demo-caption.module.css | 16 + .../product-demo-caption.test.tsx | 70 ++ .../product-demo-caption.tsx | 87 ++ .../product-demo-visual-mount.tsx | 62 +- .../components/product-demo/product-demo.tsx | 206 ++++- .../app/(landing)/components/proof/index.ts | 1 + .../app/(landing)/components/proof/proof.tsx | 22 + .../components/prose-page/constants.ts | 8 +- .../components/section-band/index.ts | 1 + .../components/section-band/section-band.tsx | 92 ++ .../components/section-heading/index.ts | 1 + .../section-heading/section-heading.tsx | 103 +++ .../cert-blocs/cert-blocs.module.css | 48 + .../components/cert-blocs/cert-blocs.test.tsx | 194 ++++ .../components/cert-blocs/cert-blocs.tsx | 161 ++++ .../security/components/cert-blocs/index.ts | 1 + .../components/workspace-controls/index.ts | 1 + .../workspace-controls.test.tsx | 45 + .../workspace-controls/workspace-controls.tsx | 111 +++ .../(landing)/components/security/icons.tsx | 227 +++++ .../(landing)/components/security/index.ts | 2 + .../components/security/security.test.tsx | 42 + .../components/security/security.tsx | 63 ++ .../shared/edge-fade/edge-fade.module.css | 110 +++ .../components/shared/edge-fade/edge-fade.tsx | 128 +++ .../components/shared/edge-fade/index.ts | 1 + .../hero-loop-shell/hero-loop-shell.tsx | 42 +- .../shared/product-preview/index.ts | 2 + .../product-preview/product-preview.tsx | 99 +++ .../components/shared/product-window/index.ts | 1 + .../product-window/product-window.test.tsx | 30 + .../shared/product-window/product-window.tsx | 53 ++ .../solutions-pill-cta/solutions-pill-cta.tsx | 16 +- .../components/solutions-page/constants.ts | 2 +- .../solutions-page/solutions-page.tsx | 37 +- .../components/trusted-by/trusted-by.tsx | 15 +- apps/sim/app/(landing)/constants.ts | 9 +- apps/sim/app/(landing)/contact/contact.tsx | 6 +- .../app/(landing)/customers/[slug]/page.tsx | 50 ++ .../customer-story-card.tsx | 34 + .../components/customer-story-card/index.ts | 1 + .../customer-story-media.tsx | 43 + .../components/customer-story-media/index.ts | 1 + .../customer-story-page.tsx | 103 +++ .../components/customer-story-page/index.ts | 1 + .../sim/app/(landing)/customers/page.test.tsx | 125 +++ apps/sim/app/(landing)/customers/page.tsx | 62 ++ .../demo-scheduler/demo-scheduler.test.tsx | 27 +- .../demo-scheduler/demo-scheduler.tsx | 12 +- apps/sim/app/(landing)/demo/demo.tsx | 8 +- .../enterprise-home-stage.tsx | 4 +- .../enterprise-platform-loop/stage-data.ts | 16 +- .../build-methods-graphic.tsx | 40 +- .../components/feature-graphics/constants.ts | 21 + .../feature-graphics/deploy-graphic.tsx | 30 +- .../feature-graphic-shell.tsx | 25 +- .../feature-platform-panel.tsx | 13 +- .../components/feature-graphics/index.ts | 2 +- .../operations-teams-graphic.tsx | 30 +- .../app/(landing)/enterprise/enterprise.tsx | 11 +- .../file-library-graphic.module.css | 46 +- .../feature-graphics/file-library-graphic.tsx | 271 +++--- .../integrations/(shell)/[slug]/page.tsx | 59 +- .../(landing)/integrations/(shell)/page.tsx | 4 +- .../connector-sync-graphic.module.css | 75 +- .../connector-sync-graphic.tsx | 216 ++--- apps/sim/app/(landing)/landing.test.tsx | 45 +- apps/sim/app/(landing)/landing.tsx | 59 +- apps/sim/app/(landing)/layout.tsx | 18 +- .../feature-graphics/run-trace-graphic.tsx | 124 ++- .../(shell)/[provider]/[model]/page.tsx | 4 +- .../models/(shell)/[provider]/page.tsx | 4 +- .../sim/app/(landing)/models/(shell)/page.tsx | 4 +- apps/sim/app/(landing)/pricing/pricing.tsx | 2 +- .../feature-graphics/table-grid-graphic.tsx | 50 +- .../workflow-canvas-graphic.tsx | 123 ++- .../app/_shell/providers/theme-provider.tsx | 30 +- apps/sim/app/sitemap.ts | 27 +- .../components/agent-group/agent-group.tsx | 15 +- .../components/status-page/status-page.tsx | 2 +- .../content/customers/exp-realty/index.mdx | 37 + apps/sim/content/customers/rivian/index.mdx | 37 + apps/sim/lib/customers/data.ts | 46 + apps/sim/lib/customers/registry.ts | 16 + apps/sim/lib/landing/routes.ts | 10 +- .../cta-san-francisco-painted-dark.webp | Bin 0 -> 4511888 bytes .../cta-san-francisco-painted-light.webp | Bin 0 -> 3907956 bytes .../customer-stories/rivian-r2-loop.mp4 | Bin 0 -> 13293008 bytes .../customer-stories/rivian-r2-poster.jpg | Bin 0 -> 290889 bytes .../public/landing/customers/rivian-trail.jpg | Bin 0 -> 236390 bytes apps/sim/public/landing/hero-painted-4k.webp | Bin 0 -> 7913742 bytes .../emcn/src/components/chip-tag/chip-tag.tsx | 14 +- packages/emcn/src/components/chip/chip.tsx | 6 +- .../src/components/tab-strip/tab-strip.tsx | 2 +- .../workflow-block-view-interaction.test.tsx | 48 +- .../workflow-block/workflow-block-view.tsx | 8 +- 272 files changed, 16798 insertions(+), 1992 deletions(-) create mode 100644 apps/sim/app/(auth)/components/auth-shell.test.tsx create mode 100644 apps/sim/app/(auth)/signup/signup-form.test.tsx create mode 100644 apps/sim/app/(landing)/components/agent-momentum/agent-momentum-metrics.tsx create mode 100644 apps/sim/app/(landing)/components/agent-momentum/agent-momentum.test.tsx create mode 100644 apps/sim/app/(landing)/components/agent-momentum/agent-momentum.tsx create mode 100644 apps/sim/app/(landing)/components/agent-momentum/index.ts create mode 100644 apps/sim/app/(landing)/components/cta/cta.module.css create mode 100644 apps/sim/app/(landing)/components/featured-customer/featured-customer-card.tsx create mode 100644 apps/sim/app/(landing)/components/featured-customer/featured-customer-navigation-button.tsx create mode 100644 apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx create mode 100644 apps/sim/app/(landing)/components/featured-customer/featured-customer.tsx create mode 100644 apps/sim/app/(landing)/components/featured-customer/index.ts create mode 100644 apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx create mode 100644 apps/sim/app/(landing)/components/features/components/core-feature-card/index.ts create mode 100644 apps/sim/app/(landing)/components/features/components/features-rail/features-rail.test.tsx create mode 100644 apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx create mode 100644 apps/sim/app/(landing)/components/features/components/features-rail/index.ts create mode 100644 apps/sim/app/(landing)/components/features/features.test.tsx create mode 100644 apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx create mode 100644 apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx create mode 100644 apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/index.ts create mode 100644 apps/sim/app/(landing)/components/footer/components/theme-toggle/index.ts create mode 100644 apps/sim/app/(landing)/components/footer/components/theme-toggle/theme-toggle.tsx create mode 100644 apps/sim/app/(landing)/components/govern/govern.tsx create mode 100644 apps/sim/app/(landing)/components/govern/index.ts create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-announcement-chip/hero-announcement-chip.test.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-announcement-chip/hero-announcement-chip.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-announcement-chip/index.ts create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.test.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-chat-welcome/hero-chat-welcome.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-chat-welcome/index.ts create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-intro/draw-platform-preview.ts create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-intro/hero-platform-intro.test.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-intro/hero-platform-intro.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-intro/index.ts create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-intro/use-platform-intro.ts create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop.test.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-loop/preview-build-timing.ts create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-loop/preview-chat-content.ts create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.module.css create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-stage/hero-platform-stage.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-platform-stage/index.ts rename apps/sim/app/(landing)/components/{mothership/components => }/iso-marks/goo-defs.tsx (100%) create mode 100644 apps/sim/app/(landing)/components/iso-marks/index.ts rename apps/sim/app/(landing)/components/{mothership/components => }/iso-marks/iso-build-illustration.tsx (100%) rename apps/sim/app/(landing)/components/{mothership/components => }/iso-marks/iso-ingest-illustration.tsx (100%) rename apps/sim/app/(landing)/components/{mothership/components => }/iso-marks/iso-integrate-illustration.tsx (100%) rename apps/sim/app/(landing)/components/{mothership/components => }/iso-marks/iso-monitor-illustration.tsx (100%) rename apps/sim/app/(landing)/components/{mothership/components => }/iso-marks/use-goo-mark.ts (100%) create mode 100644 apps/sim/app/(landing)/components/landing-cta-link/index.ts create mode 100644 apps/sim/app/(landing)/components/landing-cta-link/landing-cta-link.tsx create mode 100644 apps/sim/app/(landing)/components/landing-shell/landing-shell.test.tsx delete mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/index.ts delete mode 100644 apps/sim/app/(landing)/components/mothership/mothership.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/announcement-banner/announcement-banner.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/announcement-banner/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.test.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-card/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-card/nav-menu-card.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-logo-marquee/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-logo-marquee/nav-menu-logo-marquee.module.css create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-logo-marquee/nav-menu-logo-marquee.test.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-logo-marquee/nav-menu-logo-marquee.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/blog-menu-preview/blog-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/blog-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/changelog-menu-preview/changelog-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/changelog-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/docs-menu-preview/docs-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/docs-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/enterprise-menu-preview/enterprise-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/enterprise-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/files-menu-preview/files-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/files-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/integrations-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/integrations-menu-preview/integrations-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/knowledge-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/knowledge-menu-preview/knowledge-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/library-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/library-menu-preview/library-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/logs-menu-preview/components/logs-run-graph/logs-run-graph.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/logs-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/logs-menu-preview/logs-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/menu-preview-frame/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/menu-preview-frame/menu-preview-frame.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/menu-preview-header/menu-preview-header.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/models-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/models-menu-preview/models-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/overview-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/overview-menu-preview/overview-menu-preview.module.css create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/overview-menu-preview/overview-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/tables-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/tables-menu-preview/tables-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/workflow-menu-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/components/workflow-menu-preview/workflow-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/nav-menu-preview.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.test.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.test.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/navbar-auth-pill/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/navbar-auth-pill/navbar-auth-pill.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/hooks/use-navbar-menu.ts create mode 100644 apps/sim/app/(landing)/components/placement-frame/index.ts create mode 100644 apps/sim/app/(landing)/components/placement-frame/placement-frame.tsx create mode 100644 apps/sim/app/(landing)/components/platform-suite/components/build-agents-loop/build-agents-loop.tsx create mode 100644 apps/sim/app/(landing)/components/platform-suite/components/build-agents-loop/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-suite/components/governance-loop/governance-loop.test.tsx create mode 100644 apps/sim/app/(landing)/components/platform-suite/components/governance-loop/governance-loop.tsx create mode 100644 apps/sim/app/(landing)/components/platform-suite/components/governance-loop/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-suite/components/iso-marks/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-suite/components/iso-marks/iso-illustration-style.ts create mode 100644 apps/sim/app/(landing)/components/platform-suite/components/iso-marks/iso-integrate-illustration.tsx create mode 100644 apps/sim/app/(landing)/components/platform-suite/components/iso-marks/iso-marks.module.css create mode 100644 apps/sim/app/(landing)/components/platform-suite/components/iso-marks/iso-monitor-illustration.tsx create mode 100644 apps/sim/app/(landing)/components/platform-suite/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-suite/platform-suite.test.tsx create mode 100644 apps/sim/app/(landing)/components/platform-suite/platform-suite.tsx create mode 100644 apps/sim/app/(landing)/components/product-demo/components/composer-loop/composer-loop.test.tsx create mode 100644 apps/sim/app/(landing)/components/product-demo/components/composer-loop/composer-loop.tsx create mode 100644 apps/sim/app/(landing)/components/product-demo/components/composer-loop/demo-composer.test.tsx create mode 100644 apps/sim/app/(landing)/components/product-demo/components/composer-loop/demo-composer.tsx create mode 100644 apps/sim/app/(landing)/components/product-demo/components/composer-loop/demo-workflow-data.test.ts create mode 100644 apps/sim/app/(landing)/components/product-demo/components/composer-loop/demo-workflow-data.ts create mode 100644 apps/sim/app/(landing)/components/product-demo/components/composer-loop/index.ts create mode 100644 apps/sim/app/(landing)/components/product-demo/components/product-demo-caption/beats.ts create mode 100644 apps/sim/app/(landing)/components/product-demo/components/product-demo-caption/context.tsx create mode 100644 apps/sim/app/(landing)/components/product-demo/components/product-demo-caption/index.ts create mode 100644 apps/sim/app/(landing)/components/product-demo/components/product-demo-caption/product-demo-caption.module.css create mode 100644 apps/sim/app/(landing)/components/product-demo/components/product-demo-caption/product-demo-caption.test.tsx create mode 100644 apps/sim/app/(landing)/components/product-demo/components/product-demo-caption/product-demo-caption.tsx create mode 100644 apps/sim/app/(landing)/components/proof/index.ts create mode 100644 apps/sim/app/(landing)/components/proof/proof.tsx create mode 100644 apps/sim/app/(landing)/components/section-band/index.ts create mode 100644 apps/sim/app/(landing)/components/section-band/section-band.tsx create mode 100644 apps/sim/app/(landing)/components/section-heading/index.ts create mode 100644 apps/sim/app/(landing)/components/section-heading/section-heading.tsx create mode 100644 apps/sim/app/(landing)/components/security/components/cert-blocs/cert-blocs.module.css create mode 100644 apps/sim/app/(landing)/components/security/components/cert-blocs/cert-blocs.test.tsx create mode 100644 apps/sim/app/(landing)/components/security/components/cert-blocs/cert-blocs.tsx create mode 100644 apps/sim/app/(landing)/components/security/components/cert-blocs/index.ts create mode 100644 apps/sim/app/(landing)/components/security/components/workspace-controls/index.ts create mode 100644 apps/sim/app/(landing)/components/security/components/workspace-controls/workspace-controls.test.tsx create mode 100644 apps/sim/app/(landing)/components/security/components/workspace-controls/workspace-controls.tsx create mode 100644 apps/sim/app/(landing)/components/security/icons.tsx create mode 100644 apps/sim/app/(landing)/components/security/index.ts create mode 100644 apps/sim/app/(landing)/components/security/security.test.tsx create mode 100644 apps/sim/app/(landing)/components/security/security.tsx create mode 100644 apps/sim/app/(landing)/components/shared/edge-fade/edge-fade.module.css create mode 100644 apps/sim/app/(landing)/components/shared/edge-fade/edge-fade.tsx create mode 100644 apps/sim/app/(landing)/components/shared/edge-fade/index.ts create mode 100644 apps/sim/app/(landing)/components/shared/product-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/shared/product-preview/product-preview.tsx create mode 100644 apps/sim/app/(landing)/components/shared/product-window/index.ts create mode 100644 apps/sim/app/(landing)/components/shared/product-window/product-window.test.tsx create mode 100644 apps/sim/app/(landing)/components/shared/product-window/product-window.tsx create mode 100644 apps/sim/app/(landing)/customers/[slug]/page.tsx create mode 100644 apps/sim/app/(landing)/customers/components/customer-story-card/customer-story-card.tsx create mode 100644 apps/sim/app/(landing)/customers/components/customer-story-card/index.ts create mode 100644 apps/sim/app/(landing)/customers/components/customer-story-media/customer-story-media.tsx create mode 100644 apps/sim/app/(landing)/customers/components/customer-story-media/index.ts create mode 100644 apps/sim/app/(landing)/customers/components/customer-story-page/customer-story-page.tsx create mode 100644 apps/sim/app/(landing)/customers/components/customer-story-page/index.ts create mode 100644 apps/sim/app/(landing)/customers/page.test.tsx create mode 100644 apps/sim/app/(landing)/customers/page.tsx create mode 100644 apps/sim/app/(landing)/enterprise/components/feature-graphics/constants.ts create mode 100644 apps/sim/content/customers/exp-realty/index.mdx create mode 100644 apps/sim/content/customers/rivian/index.mdx create mode 100644 apps/sim/lib/customers/data.ts create mode 100644 apps/sim/lib/customers/registry.ts create mode 100644 apps/sim/public/landing/cta-san-francisco-painted-dark.webp create mode 100644 apps/sim/public/landing/cta-san-francisco-painted-light.webp create mode 100644 apps/sim/public/landing/customer-stories/rivian-r2-loop.mp4 create mode 100644 apps/sim/public/landing/customer-stories/rivian-r2-poster.jpg create mode 100644 apps/sim/public/landing/customers/rivian-trail.jpg create mode 100644 apps/sim/public/landing/hero-painted-4k.webp diff --git a/apps/sim/app/(auth)/components/auth-shell.test.tsx b/apps/sim/app/(auth)/components/auth-shell.test.tsx new file mode 100644 index 00000000000..2248773b9f6 --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-shell.test.tsx @@ -0,0 +1,24 @@ +/** @vitest-environment node */ +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { expect, it, vi } from 'vitest' +import { AuthShell } from '@/app/(auth)/components/auth-shell' + +vi.mock('next/link', () => ({ + default: ({ children }: { children: ReactNode }) => ( + + {children} + + ), +})) +vi.mock('@/app/_shell/desktop-title-bar', () => ({ DesktopTitleBarLane: () => null })) +vi.mock('@/app/(landing)/components/navbar/components', () => ({ + LogoMark: ({ children }: { children: ReactNode }) => <>{children}, + SimWordmark: () => 'Sim', +})) + +it('returns home through a document link so route-specific theme defaults reinitialize', () => { + const html = renderToStaticMarkup(Sign in) + expect(html).toContain('href="/" aria-label="Sim home"') + expect(html).not.toContain('data-client-navigation') +}) diff --git a/apps/sim/app/(auth)/components/auth-shell.tsx b/apps/sim/app/(auth)/components/auth-shell.tsx index 36085dc52ad..d7107dfeb96 100644 --- a/apps/sim/app/(auth)/components/auth-shell.tsx +++ b/apps/sim/app/(auth)/components/auth-shell.tsx @@ -1,5 +1,4 @@ import type { ReactNode } from 'react' -import Link from 'next/link' import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components' @@ -19,6 +18,8 @@ interface AuthShellProps { * the canvas/`--text-primary` surface, and renders a logo-only header that reuses * the landing {@link LogoMark} + {@link SimWordmark} at the same nav gutters. The * single content column is centered and capped for a calm single-form layout. + * The home link starts a document navigation so the marketing theme default is + * initialized independently of auth's forced-light context. * * The shell also owns the macOS traffic-light lane, unconditionally — every surface that * wears it (the `(auth)` routes, the CLI auth handoff, the invite pages) sits outside @@ -34,11 +35,11 @@ export function AuthShell({ children, footer }: AuthShellProps) {
diff --git a/apps/sim/app/(auth)/signup/signup-form.test.tsx b/apps/sim/app/(auth)/signup/signup-form.test.tsx new file mode 100644 index 00000000000..fac82dd3667 --- /dev/null +++ b/apps/sim/app/(auth)/signup/signup-form.test.tsx @@ -0,0 +1,132 @@ +/** @vitest-environment jsdom */ +import { act, type InputHTMLAttributes, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import SignupForm from '@/app/(auth)/signup/signup-form' + +const { push, signUp, refetchSession } = vi.hoisted(() => ({ + push: vi.fn(), + signUp: vi.fn(), + refetchSession: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push }), + useSearchParams: () => new URLSearchParams(), +})) +vi.mock('@marsidev/react-turnstile', () => ({ Turnstile: () => null })) +vi.mock('posthog-js/react', () => ({ usePostHog: () => null })) +vi.mock('@/lib/analytics/google', () => ({ trackGoogleEvent: vi.fn() })) +vi.mock('@/lib/auth/auth-client', () => ({ + client: { signUp: { email: signUp } }, + useSession: () => ({ refetch: refetchSession }), +})) +vi.mock('@/lib/consent/tracking-consent', () => ({ + useTrackingConsent: () => ({ measurement: false }), +})) +vi.mock('@/lib/core/config/env', () => ({ getEnv: () => undefined, isFalsy: () => false })) +vi.mock('@/lib/core/config/env-flags', () => ({ isSsoEnabled: false })) +vi.mock('@/lib/core/security/input-validation', () => ({ validateCallbackUrl: () => false })) +vi.mock('@/lib/messaging/email/validation', () => ({ + quickValidateEmail: () => ({ isValid: true }), +})) +vi.mock('@/lib/posthog/client', () => ({ captureClientEvent: vi.fn(), captureEvent: vi.fn() })) +vi.mock('@/app/(auth)/components', () => ({ + AuthDivider: () => null, + AuthField: ({ children }: { children: ReactNode }) => <>{children}, + AuthFormMessage: () => null, + AuthHeader: () => null, + AuthInput: ({ error, ...props }: InputHTMLAttributes & { error?: boolean }) => ( + + ), + AuthLegalFooter: () => null, + AuthNavPrompt: () => null, + AuthSubmitButton: ({ children }: { children: ReactNode }) => ( + + ), + PasswordInput: ({ + error, + ...props + }: InputHTMLAttributes & { error?: boolean }) => , + SocialLoginButtons: () => null, + SSOLoginButton: () => null, +})) + +let root: Root +let host: HTMLDivElement +let destination: { href: string } + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + destination = { href: '' } + const browser = window + vi.stubGlobal( + 'window', + new Proxy(browser, { + get(target, key) { + return key === 'location' ? destination : Reflect.get(target, key, target) + }, + }) + ) + signUp.mockResolvedValue({ data: { user: { id: 'new-user' } } }) + refetchSession.mockResolvedValue(undefined) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.unstubAllGlobals() +}) + +async function submit(emailVerificationEnabled: boolean) { + act(() => + root.render( + + ) + ) + const fields = { name: 'Test Builder', email: 'builder@example.com', password: 'SafePass1!' } + for (const [name, value] of Object.entries(fields)) { + const input = host.querySelector(`input[name="${name}"]`) + if (!input) throw new Error(`Missing ${name} input`) + input.value = value + } + const form = host.querySelector('form') + if (!form) throw new Error('Missing signup form') + await act(async () => + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) + ) +} + +describe('signup shell navigation', () => { + it('starts a document navigation after a successful signup without verification', async () => { + await submit(false) + expect(signUp).toHaveBeenCalledOnce() + expect(refetchSession).toHaveBeenCalledOnce() + expect(destination.href).toBe('/workspace') + expect(push).not.toHaveBeenCalled() + }) + + it('keeps verification within the auth shell and stores the email for the next step', async () => { + await submit(true) + expect(push).toHaveBeenCalledWith('/verify?fromSignup=true') + expect(sessionStorage.getItem('verificationEmail')).toBe('builder@example.com') + expect(destination.href).toBe('') + }) + + it('does not navigate when signup fails', async () => { + signUp.mockResolvedValue({ error: { message: 'Signup failed' } }) + await submit(false) + expect(push).not.toHaveBeenCalled() + expect(destination.href).toBe('') + }) +}) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 61ad48a8328..e945c6ec16f 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -372,12 +372,10 @@ function SignupFormContent({ if (destination.kind === 'verify') { router.push(VERIFY_FROM_SIGNUP_ROUTE) - } else if (destination.kind === 'redirect') { - // Full navigation, matching the verify hop: the destination (invite, CLI - // handoff) is server-rendered and must see the fresh session cookie. - window.location.href = destination.url } else { - router.push(DEFAULT_POST_AUTH_ROUTE) + /** Match login/verification: refresh session-bound shells and their theme default. */ + window.location.href = + destination.kind === 'redirect' ? destination.url : DEFAULT_POST_AUTH_ROUTE } } catch (error) { logger.error('Signup error:', error) diff --git a/apps/sim/app/(landing)/CLAUDE.md b/apps/sim/app/(landing)/CLAUDE.md index 16c31795602..0f087003f79 100644 --- a/apps/sim/app/(landing)/CLAUDE.md +++ b/apps/sim/app/(landing)/CLAUDE.md @@ -6,14 +6,14 @@ This route group owns `/` and the entire public marketing surface - the home pag ## What this is -- `app/(landing)/` - the marketing site. A shared `layout.tsx` renders the chrome once (the `LandingShell`: light tokens, navbar with server-side GitHub stars, footer, site-wide JSON-LD); each page supplies only its `
` content. +- `app/(landing)/` - the marketing site. A shared `layout.tsx` renders the chrome once (the `LandingShell`: light tokens, navbar with server-side GitHub stars, painted pre-footer CTA, footer, site-wide JSON-LD); each page supplies only its `
` content. The painted light/dark CTA and footer are owned by `LandingShell`; never add page-specific closing CTA bands or footer instances. - The legacy `app/(home)/` group (old dark landing + `--landing-*` tokens) has been **deleted** - its marketing pages were migrated here and its chrome retired. Do not reintroduce `--landing-*` tokens, Martian Mono accents, or a separate marketing theme. -## Styling - draw from the platform's light mode +## Styling - draw from the platform's tokens -The landing page looks like the product. Its visual language is the workspace UI in light mode, not a separate marketing theme. +The landing page looks like the product. Its visual language is the workspace UI - light by default, dark on request - not a separate marketing theme. -- **Always light.** The root wrapper in `landing.tsx` carries the `light` class, which pins every token to its light value (see `app/_styles/globals.css`, the `:root, .light` block). Never add `dark:` variants here; never read the user's theme. +- **Light by default, dark on request.** The landing family follows the theme class on `` (next-themes, storage key `sim-theme`): a first-time visitor gets light, the design baseline, and the footer's `ThemeToggle` switches to dark - the platform's own `.dark` token values from `app/_styles/globals.css`, no separate palette. Tokens flip on their own, so `dark:` variants exist here only to pair the handful of deliberate literals (the `#F8F8F8` paper band, the composer send button, the pale CTA drawing) with their dark value in the same class string - never leave a literal unpaired. Never read the theme in a Server Component; the toggle is the one client reader. - **Use platform tokens, never hex.** Canvas `--bg`, surfaces `--surface-1`…`--surface-7`, cards/modals `--surface-2`, hover `--surface-hover`, active `--surface-active`; text `--text-primary` / `--text-secondary` / `--text-muted` / `--text-body`, icons `--text-icon`; borders `--border` (dividers) / `--border-1` (fields); brand `--brand-agent` / `--brand-secondary` / `--brand-accent`. Do **not** use the legacy `--landing-*` tokens - they belong to the old dark landing. - **Use emcn components where they fit.** The chip family (`Chip`, `ChipLink`, `ChipTag`, `ChipInput`, `ChipModal*`, …) from `@/components/emcn` is the canonical chrome - a demo-request form is a `ChipModal` with `ChipModalField`s, a pill CTA is a `Chip`/`ChipLink`. Components own their chrome; pass props, not className overrides. Full consumer rules: `.claude/rules/sim-styling.md`. - **Typography is the platform's.** Season is the global body font (`font-season` is applied on `` in the root layout). Use the platform text scale (`text-small` = 13px, `text-base` = 15px, etc. - see the `@theme` block in `app/_styles/globals.css`). Don't add new fonts or font CSS variables without explicit direction. @@ -99,7 +99,7 @@ Absolute imports only in component code (`@/app/(landing)/components/...`); `ind 1. Server Component unless it provably needs client state; if client, it's a leaf. 2. H2 with `id` + `aria-labelledby` wiring; heading hierarchy intact. -3. Platform light tokens and emcn chrome only - no hex colors, no `--landing-*`, no `dark:`. +3. Platform tokens and emcn chrome only - no hex colors, no `--landing-*`. Check the section in dark mode too (footer toggle); any deliberate literal carries its `dark:` pair. 4. Images: `next/image`, explicit dimensions, `priority` only on the LCP element. 5. Copy passes the constitution (language table, claim hierarchy, tone). 6. "Sim" named explicitly; section quotable in isolation. diff --git a/apps/sim/app/(landing)/careers/careers.tsx b/apps/sim/app/(landing)/careers/careers.tsx index 56c4d2fe194..b9f8d7de184 100644 --- a/apps/sim/app/(landing)/careers/careers.tsx +++ b/apps/sim/app/(landing)/careers/careers.tsx @@ -21,8 +21,8 @@ interface CareersProps { * crawlable HTML; the interactive {@link JobBoard} hydrates on top to add * Team/Location filtering. * - * Both sections share the landing gutter — capped and centered at `max-w-[1460px]` - * with the navbar-aligned `px-20 max-lg:px-8 max-sm:px-5` so the headline starts on + * Both sections share the landing gutter — capped and centered at `max-w-[1728px]` + * with the navbar-aligned `px-10 max-md:px-7 max-lg:px-8 max-xl:px-9` so the headline starts on * the same vertical line as the wordmark. The hero carries the single `

` * (containing "Sim" and "AI workspace") plus an sr-only product summary for AI * citation (landing CLAUDE.md → GEO); the roles section owns its own `

`. @@ -45,7 +45,7 @@ export default async function Careers({ searchParams }: CareersProps) {

Careers at Sim, the open-source AI workspace where teams build, deploy, and manage AI @@ -70,7 +70,7 @@ export default async function Careers({ searchParams }: CareersProps) {

- -
- -
) } diff --git a/apps/sim/app/(landing)/components/agent-momentum/agent-momentum-metrics.tsx b/apps/sim/app/(landing)/components/agent-momentum/agent-momentum-metrics.tsx new file mode 100644 index 00000000000..1a045265e38 --- /dev/null +++ b/apps/sim/app/(landing)/components/agent-momentum/agent-momentum-metrics.tsx @@ -0,0 +1,149 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { cn } from '@sim/emcn' +import { HOME_TYPE } from '@/app/(landing)/components/landing-layout' + +const COUNT_DURATION_MS = 900 +const METRIC_STAGGER_MS = 120 +const NUMBER_FORMATTER = new Intl.NumberFormat('en-US') +const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 2, +}) + +export interface AgentMomentumMetric { + value: number + compact?: boolean + prefix?: string + suffix?: string + description: string +} + +interface AgentMomentumMetricsProps { + metrics: readonly AgentMomentumMetric[] +} + +/** Scroll-triggered impact counters that run once, with a reduced-motion fallback. */ +export function AgentMomentumMetrics({ metrics }: AgentMomentumMetricsProps) { + const listRef = useRef(null) + const animationFrameRef = useRef(null) + const hasAnimatedRef = useRef(false) + const [counts, setCounts] = useState(() => metrics.map(() => 0)) + + useEffect(() => { + const list = listRef.current + if (!list) return + + const media = window.matchMedia('(prefers-reduced-motion: reduce)') + let observer: IntersectionObserver | null = null + + const finishImmediately = () => { + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current) + } + hasAnimatedRef.current = true + setCounts(metrics.map((metric) => metric.value)) + } + + const animate = () => { + if (hasAnimatedRef.current) return + hasAnimatedRef.current = true + const startedAt = performance.now() + + const tick = (now: number) => { + let isComplete = true + const nextCounts = metrics.map((metric, index) => { + const elapsed = now - startedAt - index * METRIC_STAGGER_MS + const progress = Math.min(Math.max(elapsed / COUNT_DURATION_MS, 0), 1) + if (progress < 1) isComplete = false + const eased = 1 - (1 - progress) ** 4 + return Math.round(metric.value * eased) + }) + + setCounts(nextCounts) + if (!isComplete) { + animationFrameRef.current = requestAnimationFrame(tick) + } + } + + animationFrameRef.current = requestAnimationFrame(tick) + } + + if (media.matches) { + finishImmediately() + } else if (typeof IntersectionObserver === 'undefined') { + animate() + } else { + observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) return + observer?.disconnect() + animate() + }, + { threshold: 0.25 } + ) + observer.observe(list) + } + + const handleMotionPreference = () => { + if (media.matches) { + observer?.disconnect() + finishImmediately() + } + } + + media.addEventListener('change', handleMotionPreference) + return () => { + observer?.disconnect() + media.removeEventListener('change', handleMotionPreference) + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current) + } + } + }, [metrics]) + + return ( +
+ {metrics.map((metric, index) => { + const isVisible = (counts[index] ?? 0) > 0 + const formatter = metric.compact ? COMPACT_NUMBER_FORMATTER : NUMBER_FORMATTER + const finalValue = `${metric.prefix ?? ''}${NUMBER_FORMATTER.format(metric.value)}${metric.suffix ?? ''}` + const formattedValue = `${metric.prefix ?? ''}${formatter.format(counts[index] ?? 0)}${metric.suffix ?? ''}` + + return ( +
0 && 'border-[var(--border)] border-t' + )} + > +
+ {metric.description} +
+
+ {finalValue} + +
+
+ ) + })} +
+ ) +} diff --git a/apps/sim/app/(landing)/components/agent-momentum/agent-momentum.test.tsx b/apps/sim/app/(landing)/components/agent-momentum/agent-momentum.test.tsx new file mode 100644 index 00000000000..1713a86b88f --- /dev/null +++ b/apps/sim/app/(landing)/components/agent-momentum/agent-momentum.test.tsx @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + cn: (...values: Array) => values.filter(Boolean).join(' '), +})) + +import { AgentMomentum } from '@/app/(landing)/components/agent-momentum/agent-momentum' + +describe('AgentMomentum', () => { + it('renders the supplied savings, hours, and builders totals in order', () => { + const markup = renderToStaticMarkup() + + expect(markup).toContain( + 'The world’s work is moving to AI agents. Sim gives teams one place to build, deploy, monitor and govern every agent across the business.' + ) + expect(markup).not.toContain(' +
+
+

+ The world’s work is moving to AI agents. Sim gives teams one place to build, deploy, + monitor and govern every agent across the business. +

+
+ + +
+ + ) +} diff --git a/apps/sim/app/(landing)/components/agent-momentum/index.ts b/apps/sim/app/(landing)/components/agent-momentum/index.ts new file mode 100644 index 00000000000..c03e5d183a7 --- /dev/null +++ b/apps/sim/app/(landing)/components/agent-momentum/index.ts @@ -0,0 +1 @@ +export { AgentMomentum } from './agent-momentum' diff --git a/apps/sim/app/(landing)/components/chevron-arrow/chevron-arrow.tsx b/apps/sim/app/(landing)/components/chevron-arrow/chevron-arrow.tsx index bd7ffc46216..73f86fdd585 100644 --- a/apps/sim/app/(landing)/components/chevron-arrow/chevron-arrow.tsx +++ b/apps/sim/app/(landing)/components/chevron-arrow/chevron-arrow.tsx @@ -1,12 +1,21 @@ +import { cn } from '@sim/emcn' + +interface ChevronArrowProps { + className?: string + /** Holds the arrow in its revealed state for the currently previewed menu item. */ + active?: boolean + strokeWidth?: number +} + /** * The animated chevron used on landing link rows (models, integrations). On - * `group-hover/link` the leading line draws in and the arrowhead nudges right. + * hover or keyboard focus, the leading line draws in and the arrowhead nudges right. * Decorative, so `aria-hidden`. */ -export function ChevronArrow() { +export function ChevronArrow({ className, active = false, strokeWidth = 1.33 }: ChevronArrowProps) { return ( ) diff --git a/apps/sim/app/(landing)/components/content-author-page/content-author-loading.tsx b/apps/sim/app/(landing)/components/content-author-page/content-author-loading.tsx index 8ba0d5e00eb..6acc472aaa3 100644 --- a/apps/sim/app/(landing)/components/content-author-page/content-author-loading.tsx +++ b/apps/sim/app/(landing)/components/content-author-page/content-author-loading.tsx @@ -6,7 +6,7 @@ const AUTHOR_POST_SKELETON_COUNT = 4 export function ContentAuthorLoading() { return (
-
+
@@ -16,7 +16,7 @@ export function ContentAuthorLoading() {
-
+
{Array.from({ length: AUTHOR_POST_SKELETON_COUNT }).map((_, i) => (
diff --git a/apps/sim/app/(landing)/components/content-author-page/content-author-page.tsx b/apps/sim/app/(landing)/components/content-author-page/content-author-page.tsx index 1d759463026..f51015706a8 100644 --- a/apps/sim/app/(landing)/components/content-author-page/content-author-page.tsx +++ b/apps/sim/app/(landing)/components/content-author-page/content-author-page.tsx @@ -2,7 +2,6 @@ import Image from 'next/image' import Link from 'next/link' import type { ContentMeta } from '@/lib/content/schema' import { BackLink } from '@/app/(landing)/components/back-link' -import { Cta } from '@/app/(landing)/components/cta/cta' import { JsonLd } from '@/app/(landing)/components/json-ld' interface ContentAuthorPageProps { @@ -36,7 +35,7 @@ export function ContentAuthorPage({
{graphJsonLd && } -
+
@@ -60,7 +59,7 @@ export function ContentAuthorPage({
-
+
{posts.map((p) => (
@@ -113,10 +112,6 @@ export function ContentAuthorPage({
- -
- -
) } diff --git a/apps/sim/app/(landing)/components/content-index-page/content-index-loading.tsx b/apps/sim/app/(landing)/components/content-index-page/content-index-loading.tsx index c5242b158b6..091031d0d2c 100644 --- a/apps/sim/app/(landing)/components/content-index-page/content-index-loading.tsx +++ b/apps/sim/app/(landing)/components/content-index-page/content-index-loading.tsx @@ -7,8 +7,8 @@ const LIST_SKELETON_COUNT = 5 export function ContentIndexLoading() { return (
-
-
+
+
diff --git a/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx b/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx index bd3e0bf8dff..6b0a2d5de27 100644 --- a/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx +++ b/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx @@ -3,7 +3,6 @@ import Image from 'next/image' import Link from 'next/link' import { paginateContentPosts } from '@/lib/content/index-list' import type { ContentMeta } from '@/lib/content/schema' -import { Cta } from '@/app/(landing)/components/cta/cta' import { JsonLd } from '@/app/(landing)/components/json-ld' interface ContentIndexPageProps { @@ -44,7 +43,7 @@ export function ContentIndexPage({
-
+

{heading} @@ -57,7 +56,7 @@ export function ContentIndexPage({
-
+
{featured.length > 0 && ( <> @@ -183,10 +182,6 @@ export function ContentIndexPage({

- -
- -
) } diff --git a/apps/sim/app/(landing)/components/content-post-page/content-post-loading.tsx b/apps/sim/app/(landing)/components/content-post-page/content-post-loading.tsx index 93b65565d23..315b5050f2c 100644 --- a/apps/sim/app/(landing)/components/content-post-page/content-post-loading.tsx +++ b/apps/sim/app/(landing)/components/content-post-page/content-post-loading.tsx @@ -4,22 +4,22 @@ import { Skeleton } from '@sim/emcn' export function ContentPostLoading() { return (
-
+
-
-
+
+
-
+
-
+
@@ -32,7 +32,7 @@ export function ContentPostLoading() {
-
+
diff --git a/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx b/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx index 9876b42894d..c81f74ed813 100644 --- a/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx +++ b/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx @@ -48,13 +48,13 @@ export function ContentPostPage({ return (
-
+
-
-
+
+
-
+

-
+
diff --git a/apps/sim/app/(landing)/components/cta/cta.module.css b/apps/sim/app/(landing)/components/cta/cta.module.css new file mode 100644 index 00000000000..58d6c8b3d4e --- /dev/null +++ b/apps/sim/app/(landing)/components/cta/cta.module.css @@ -0,0 +1,36 @@ +/** + * A soft central cutout lets painted sky rise alongside the overlapping copy. + * The vertical mask dissolves the outer boundaries into the theme canvas; + * the landscape retains its full detail below the content. + */ +.plate { + --plate-mask: linear-gradient( + to bottom, + rgb(0 0 0 / 0) 0%, + rgb(0 0 0 / 0.074) 1.7%, + rgb(0 0 0 / 0.259) 3.3%, + rgb(0 0 0 / 0.5) 5%, + rgb(0 0 0 / 0.741) 6.7%, + rgb(0 0 0 / 0.926) 8.3%, + rgb(0 0 0 / 1) 10%, + rgb(0 0 0 / 1) 80%, + rgb(0 0 0 / 0.926) 83.3%, + rgb(0 0 0 / 0.741) 86.7%, + rgb(0 0 0 / 0.5) 90%, + rgb(0 0 0 / 0.259) 93.3%, + rgb(0 0 0 / 0.074) 96.7%, + rgb(0 0 0 / 0) 100% + ); + --copy-mask: radial-gradient( + ellipse 52% 38% at 50% 0%, + rgb(0 0 0 / 0) 24%, + rgb(0 0 0 / 0.08) 40%, + rgb(0 0 0 / 0.35) 58%, + rgb(0 0 0 / 0.75) 78%, + rgb(0 0 0 / 1) 100% + ); + -webkit-mask-image: var(--plate-mask), var(--copy-mask); + mask-image: var(--plate-mask), var(--copy-mask); + -webkit-mask-composite: source-in; + mask-composite: intersect; +} diff --git a/apps/sim/app/(landing)/components/cta/cta.tsx b/apps/sim/app/(landing)/components/cta/cta.tsx index b82a6bcf7ef..94354db852a 100644 --- a/apps/sim/app/(landing)/components/cta/cta.tsx +++ b/apps/sim/app/(landing)/components/cta/cta.tsx @@ -1,40 +1,70 @@ -import { ChipLink } from '@sim/emcn' -import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants' +import { cn } from '@sim/emcn' +import Image from 'next/image' +import styles from '@/app/(landing)/components/cta/cta.module.css' +import { HeroCta } from '@/app/(landing)/components/hero-cta' +import { + HOME_TYPE, + LANDING_CONTENT_WIDTH, + LANDING_GUTTER, +} from '@/app/(landing)/components/landing-layout' + +/** One shared closing statement, with a deliberate line break between sentences. */ +const CTA_HEADLINE = ['Every agent your company runs.', 'All in one place.'] as const /** - * Landing pre-footer CTA - the page's final conversion band. A tall, centered - * closing band with a large headline over two pill actions - a primary - * "Get started" routing to sign-up and an outline "Contact sales" routing to - * the demo-booking page. - * - * The band carries no vertical padding of its own: its spacious closing moment - * comes from the uniform inter-section `gap` (owned by the `
` flex in - * `landing.tsx`) above it and the `Footer`'s top margin below it. The headline - * mirrors the hero `

` exactly (48px / `leading-[1.1]` and the same responsive - * ramp), so the page opens and closes on the same display size. Horizontal - * padding (`px-20`) matches every section above, and the section is capped and - * centered at the shared `max-w-[1460px]`. + * Painted pre-footer CTA for every marketing page, mounted once by LandingShell. + * Theme classes select the matching lazy-loaded painting without client state. + * The sky mask keeps the copy clear and the lower edge fades into the footer. */ export function Cta() { return (
-

+

+ {CTA_HEADLINE.map((line) => ( + + {line}{' '} + + ))} +

+
+ +
+

+ +

-
- - Get started - - - Contact sales - + +
) diff --git a/apps/sim/app/(landing)/components/featured-customer/featured-customer-card.tsx b/apps/sim/app/(landing)/components/featured-customer/featured-customer-card.tsx new file mode 100644 index 00000000000..d0ba7210af0 --- /dev/null +++ b/apps/sim/app/(landing)/components/featured-customer/featured-customer-card.tsx @@ -0,0 +1,118 @@ +import { ChipTag, cn } from '@sim/emcn' +import Image from 'next/image' +import { LANDING_STAGE_RADIUS } from '@/app/(landing)/components/landing-layout' + +/** A customer wordmark from `/public/landing/logos`, sized like the logo strip sizes it: by optical height at its own aspect. */ +export interface FeaturedCustomerLogo { + src: string + alt: string + /** Intrinsic width ÷ height, so the mark scales without distortion. */ + aspect: number + /** Display height in px, tuned by eye for the card. */ + height: number +} + +export interface FeaturedCustomerStory { + id: string + company: string + caption: string + media: { kind: 'video'; poster: string; src: string; alt: string } | { kind: 'brand' } + /** The customer's wordmark, shown under the editorial product caption. */ + logo: FeaturedCustomerLogo +} + +/** + * The wordmark assets are a single dark ink; over the film they are flattened + * and inverted to white, then eased back to sit with the caption's tone. + */ +const LOGO_ON_FILM = 'brightness-0 invert opacity-80' + +interface FeaturedCustomerCardProps { + story: FeaturedCustomerStory + active: boolean + emphasized: boolean +} + +/** Shared media and editorial caption treatment for each featured-customer slide. */ +export function FeaturedCustomerCard({ story, active, emphasized }: FeaturedCustomerCardProps) { + const isFilm = story.media.kind === 'video' + + return ( +
+ {story.media.kind === 'video' && ( + <> + {story.media.alt} + {active && ( +
+ ) +} diff --git a/apps/sim/app/(landing)/components/featured-customer/featured-customer-navigation-button.tsx b/apps/sim/app/(landing)/components/featured-customer/featured-customer-navigation-button.tsx new file mode 100644 index 00000000000..9a77c120312 --- /dev/null +++ b/apps/sim/app/(landing)/components/featured-customer/featured-customer-navigation-button.tsx @@ -0,0 +1,124 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { Button, cn, Tooltip } from '@sim/emcn' +import { ArrowLeft, ArrowRight } from '@sim/emcn/icons' + +const HOVER_NAVIGATION_DELAY_MS = 1000 + +interface FeaturedCustomerNavigationButtonProps { + direction: 'next' | 'previous' + label: string + /** No story lies in this direction: the arrow stays as a landmark but takes no input. */ + disabled?: boolean + onSelect: () => void +} + +/** + * One arrow of the carousel's control pair, drawn for the page ground in the + * landing's ink tokens: a live arrow is full ink inside a mid-grey ring, a + * disabled one a lighter grey inside a hairline-grey ring, so both read on the + * white and the live one reads first. Advances on click or after a sustained + * mouse hover; positioned by the pair that holds it, not by itself. + */ +export function FeaturedCustomerNavigationButton({ + direction, + label, + disabled = false, + onSelect, +}: FeaturedCustomerNavigationButtonProps) { + const hoverTimerRef = useRef(null) + const [isHovering, setIsHovering] = useState(false) + const [showTooltipContent, setShowTooltipContent] = useState(true) + const Icon = direction === 'next' ? ArrowRight : ArrowLeft + + const stopHoverProgress = () => { + if (hoverTimerRef.current !== null) { + window.clearTimeout(hoverTimerRef.current) + hoverTimerRef.current = null + } + setIsHovering(false) + } + + const selectStory = () => { + stopHoverProgress() + setShowTooltipContent(false) + onSelect() + } + + const startHoverProgress = () => { + if (disabled) return + stopHoverProgress() + setShowTooltipContent(true) + setIsHovering(true) + hoverTimerRef.current = window.setTimeout(selectStory, HOVER_NAVIGATION_DELAY_MS) + } + + useEffect(() => { + return () => { + if (hoverTimerRef.current !== null) { + window.clearTimeout(hoverTimerRef.current) + } + } + }, []) + + return ( + + + + + {showTooltipContent && !disabled && {label}} + + ) +} diff --git a/apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx b/apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx new file mode 100644 index 00000000000..eb4e5da3a8c --- /dev/null +++ b/apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx @@ -0,0 +1,248 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + Button: (props: React.ButtonHTMLAttributes) =>
+ ) + })} + + + {activeStory.company} customer story selected + +
+
+
+
+ ) +} diff --git a/apps/sim/app/(landing)/components/featured-customer/index.ts b/apps/sim/app/(landing)/components/featured-customer/index.ts new file mode 100644 index 00000000000..48a3444e25f --- /dev/null +++ b/apps/sim/app/(landing)/components/featured-customer/index.ts @@ -0,0 +1 @@ +export { FeaturedCustomer } from './featured-customer' diff --git a/apps/sim/app/(landing)/components/features/components/build-callout/components/build-chat-animation/build-chat-animation.tsx b/apps/sim/app/(landing)/components/features/components/build-callout/components/build-chat-animation/build-chat-animation.tsx index d17b94f807f..f240f860520 100644 --- a/apps/sim/app/(landing)/components/features/components/build-callout/components/build-chat-animation/build-chat-animation.tsx +++ b/apps/sim/app/(landing)/components/features/components/build-callout/components/build-chat-animation/build-chat-animation.tsx @@ -243,7 +243,7 @@ export function BuildChatAnimation() { >
-
+
)}

- - + +

diff --git a/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx b/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx new file mode 100644 index 00000000000..762c38d2a13 --- /dev/null +++ b/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' +import Link from 'next/link' +import { LANDING_STAGE_RADIUS } from '@/app/(landing)/components/landing-layout' + +interface CoreFeatureCardProps { + title: string + description: string + href: string + visual: ReactNode + tone?: 'light' | 'mid' | 'dark' +} + +const TONE_CLASSES = { + light: 'bg-[var(--surface-3)]', + mid: 'bg-[var(--surface-5)]', + dark: 'bg-[var(--text-secondary)]', +} as const + +/** + * Lets a horizontal gesture over the illustration reach the rail underneath. + * + * The graphics crop themselves with `overflow-hidden`, which makes every crop + * box a scroll container, and the global base rule gives every element + * `overscroll-behavior-x: none`. A trackpad swipe or horizontal wheel landing + * anywhere on the stage would otherwise stop dead at the first crop it hits + * instead of chaining out to the scroller, so the rail only scrolled from the + * caption below the stage. Nothing inside the stage scrolls on its own, so + * chaining out is always right, and the rail's own `overscroll-x-contain` + * still keeps the gesture from running on into the page. + */ +const SCROLL_THROUGH = 'overscroll-x-auto [&_*]:overscroll-x-auto' + +/** + * One homepage product module: a tall product-UI stage followed by a compact, + * independently quotable title and description. The whole module is a real + * route link, while the illustration remains decorative and non-interactive. + */ +export function CoreFeatureCard({ + title, + description, + href, + visual, + tone = 'light', +}: CoreFeatureCardProps) { + return ( +
+ + + +

+ {title} +

+

+ {description} +

+ +
+ ) +} diff --git a/apps/sim/app/(landing)/components/features/components/core-feature-card/index.ts b/apps/sim/app/(landing)/components/features/components/core-feature-card/index.ts new file mode 100644 index 00000000000..8f9d315d97e --- /dev/null +++ b/apps/sim/app/(landing)/components/features/components/core-feature-card/index.ts @@ -0,0 +1 @@ +export { CoreFeatureCard } from './core-feature-card' diff --git a/apps/sim/app/(landing)/components/features/components/feature-card/feature-card.tsx b/apps/sim/app/(landing)/components/features/components/feature-card/feature-card.tsx index 5329bb0ed2d..f29321f5410 100644 --- a/apps/sim/app/(landing)/components/features/components/feature-card/feature-card.tsx +++ b/apps/sim/app/(landing)/components/features/components/feature-card/feature-card.tsx @@ -89,7 +89,7 @@ export function FeatureCard({ src={backdropSrc} alt='' fill - sizes='(max-width: 1460px) 70vw, 900px' + sizes='(max-width: 1728px) 70vw, 1150px' className='object-cover' /> )} @@ -107,16 +107,16 @@ export function FeatureCard({ {eyebrow} -

+

{title}

-

+

{description}

{href && linkLabel && ( {linkLabel} diff --git a/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.test.tsx b/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.test.tsx new file mode 100644 index 00000000000..953b3a8cd13 --- /dev/null +++ b/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.test.tsx @@ -0,0 +1,193 @@ +/** + * @vitest-environment jsdom + */ +import { act, StrictMode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { + FeaturesRail, + foldScrollLeft, +} from '@/app/(landing)/components/features/components/features-rail/features-rail' + +const CARDS = ['Chat', 'Workflows', 'Tables'] as const + +/** Slot pitch the layout stub uses: a card plus the rail's gap. */ +const PITCH = 444 +/** One set of three cards, in the stubbed layout. */ +const SET = CARDS.length * PITCH + +function cards() { + return CARDS.map((title) => ( + + {title} + + )) +} + +const scrollLefts = new WeakMap() +const originalOffsetLeft = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetLeft') +const originalScrollLeft = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft') + +/** + * jsdom has no layout, so slots report a synthetic `offsetLeft` from their + * index, and `scrollLeft` round-trips through a map instead of a scrollport. + */ +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetLeft', { + configurable: true, + get(this: HTMLElement) { + const parent = this.parentElement + if (!parent || !this.hasAttribute('data-copy')) return 0 + return Array.prototype.indexOf.call(parent.children, this) * PITCH + }, + }) + Object.defineProperty(Element.prototype, 'scrollLeft', { + configurable: true, + get(this: HTMLElement) { + return scrollLefts.get(this) ?? 0 + }, + set(this: HTMLElement, value: number) { + scrollLefts.set(this, value) + }, + }) +}) + +afterAll(() => { + if (originalOffsetLeft) + Object.defineProperty(HTMLElement.prototype, 'offsetLeft', originalOffsetLeft) + if (originalScrollLeft) Object.defineProperty(Element.prototype, 'scrollLeft', originalScrollLeft) +}) + +let root: Root | null = null +let host: HTMLDivElement | null = null + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root?.unmount()) + root = null + host?.remove() + host = null +}) + +function mount(strict = false): HTMLElement { + const rail = {cards()} + act(() => root?.render(strict ? {rail} : rail)) + const el = host?.querySelector('[aria-label="Core Sim features"]') + if (!el) throw new Error('rail did not render') + return el +} + +function scrollTo(rail: HTMLElement, left: number): number { + rail.scrollLeft = left + rail.dispatchEvent(new Event('scroll')) + return rail.scrollLeft +} + +describe('foldScrollLeft', () => { + it('leaves the home range alone and folds by exactly one set width', () => { + expect(foldScrollLeft(1000, 1000)).toBe(1000) + expect(foldScrollLeft(500, 1000)).toBe(500) + expect(foldScrollLeft(499, 1000)).toBe(1499) + expect(foldScrollLeft(1500, 1000)).toBe(500) + expect(foldScrollLeft(2600, 1000)).toBe(600) + expect(foldScrollLeft(-200, 1000)).toBe(800) + }) + + it('does nothing without a measurable set', () => { + expect(foldScrollLeft(42, 0)).toBe(42) + }) +}) + +describe('FeaturesRail', () => { + it('server-renders the finite rail once, with the scroll chrome', () => { + const html = renderToStaticMarkup( + {cards()} + ) + + expect(html).toContain('aria-label="Core Sim features"') + expect(html).toContain('overflow-x-auto') + expect(html).not.toContain('snap-') + expect(html.match(/data-copy="home"/g)).toHaveLength(3) + expect(html).not.toContain('data-copy="lead"') + expect(html).not.toContain('data-copy="tail"') + }) + + it('loops after hydration with clones off the tab order and accessibility tree', () => { + const rail = mount() + + expect(rail.querySelectorAll('[data-copy="lead"]')).toHaveLength(3) + expect(rail.querySelectorAll('[data-copy="home"]')).toHaveLength(3) + expect(rail.querySelectorAll('[data-copy="tail"]')).toHaveLength(3) + expect(rail.querySelectorAll('[data-copy="lead"][aria-hidden="true"]')).toHaveLength(3) + expect(rail.querySelectorAll('[data-copy="tail"][aria-hidden="true"]')).toHaveLength(3) + expect(rail.querySelectorAll('[data-copy="home"][aria-hidden]')).toHaveLength(0) + + const cloneLinks = rail.querySelectorAll('[data-copy="lead"] a, [data-copy="tail"] a') + expect(cloneLinks).toHaveLength(6) + for (const link of cloneLinks) { + expect(link.getAttribute('tabindex')).toBe('-1') + } + expect(rail.querySelectorAll('[data-copy="home"] a[tabindex]')).toHaveLength(0) + expect(rail.children).toHaveLength(9) + }) + + it('rests on the middle copy after hydration, once, even when Strict Mode reruns the effect', () => { + expect(mount().scrollLeft).toBe(SET) + act(() => root?.unmount()) + root = createRoot(host as HTMLDivElement) + expect(mount(true).scrollLeft).toBe(SET) + }) + + it('drags with the mouse, scrolling by the pointer delta and swallowing the click', () => { + const rail = mount() + const link = rail.querySelector('[data-copy="home"] a') + if (!link) throw new Error('no home link') + /** jsdom has no PointerEvent; a MouseEvent carrying the pointer fields is what the handlers read. */ + const pointer = (type: string, clientX: number) => { + const event = new MouseEvent(type, { bubbles: true, button: 0, clientX }) + Object.defineProperties(event, { pointerType: { value: 'mouse' }, pointerId: { value: 1 } }) + return event + } + + rail.scrollLeft = SET + link.dispatchEvent(pointer('pointerdown', 300)) + link.dispatchEvent(pointer('pointermove', 303)) + expect(rail.scrollLeft).toBe(SET) + link.dispatchEvent(pointer('pointermove', 260)) + expect(rail.scrollLeft).toBe(SET + 40) + expect(rail.dataset.dragging).toBe('') + link.dispatchEvent(pointer('pointerup', 260)) + expect(rail.dataset.dragging).toBeUndefined() + + const click = new MouseEvent('click', { bubbles: true, cancelable: true }) + link.dispatchEvent(click) + expect(click.defaultPrevented).toBe(true) + + let swallowed: boolean | null = null + link.addEventListener( + 'click', + (event) => { + swallowed = event.defaultPrevented + event.preventDefault() + }, + { once: true } + ) + link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + expect(swallowed).toBe(false) + }) + + it('folds the position back into the middle copy as the user scrolls past it', () => { + const rail = mount() + + expect(scrollTo(rail, SET + 200)).toBe(SET + 200) + expect(scrollTo(rail, SET * 1.5 + 100)).toBe(SET * 0.5 + 100) + expect(scrollTo(rail, SET * 0.5 - 60)).toBe(SET * 1.5 - 60) + }) +}) diff --git a/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx b/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx new file mode 100644 index 00000000000..b48a31f08df --- /dev/null +++ b/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx @@ -0,0 +1,243 @@ +'use client' + +import { Children, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { cn } from '@sim/emcn' +import { EdgeFade } from '@/app/(landing)/components/shared/edge-fade' + +/** + * Breaks the rail out of the 10/12 inset to the section's edges. The section + * is a size container (`container-type: inline-size`), so `50cqw - 50%` is + * exactly the inset's offset from the section edge: as a negative margin it + * spans the rail edge to edge, and the same value as padding keeps the first + * card aligned with the heading while cards clip at the viewport rather than + * at the inset. + */ +const RAIL_BLEED = '-mx-[calc(50cqw_-_50%)] px-[calc(50cqw_-_50%)]' + +/** + * The edge-fade overlay, stretched over the same edge-to-edge span the rail + * bleeds to. The overlay is positioned against the rail's wrapper, which keeps + * the inset width, so `50%` here is the same half-inset {@link RAIL_BLEED} + * subtracts - pulling each side out to the section edge. Above the cards + * (below the navbar's `z-50`) and transparent to the pointer, so the fade only + * ever paints. + */ +const RAIL_EDGE_SPAN = + 'pointer-events-none absolute inset-y-0 z-10 left-[calc(50%_-_50cqw)] right-[calc(50%_-_50cqw)]' + +/** One card slot: fixed responsive width. */ +const SLOT_CLASS = 'w-[min(78vw,420px)] shrink-0 max-sm:w-[84vw]' + +/** Pointer travel before a mouse press on the rail becomes a drag instead of a click. */ +const DRAG_THRESHOLD_PX = 6 + +/** + * A fold moves the position by a whole set width, so anything smaller than + * this is floating-point noise from re-deriving an in-range position. + */ +const FOLD_TOLERANCE = 1 + +type Copy = 'lead' | 'home' | 'tail' + +/** + * Folds a scroll position back into the loop's home range. Three copies of the + * set sit side by side; the position is kept between half a set and a set and + * a half in, so a full set of cards is always waiting on either side and every + * fold - an exact set width, onto identical content - is invisible. A + * `setWidth` of 0 means the loop is not measurable (no layout), so the position + * is left alone. + */ +export function foldScrollLeft(scrollLeft: number, setWidth: number): number { + if (setWidth <= 0) return scrollLeft + const from = setWidth / 2 + const offset = (((scrollLeft - from) % setWidth) + setWidth) % setWidth + return from + offset +} + +interface SlotsProps { + copy: Copy + cards: ReactNode[] +} + +/** + * One copy of the set as flat flex children, so the gap between copies equals + * the gap between cards and the loop's period is exactly one set width. Clones + * stay clickable but leave the accessibility tree. + */ +function Slots({ copy, cards }: SlotsProps) { + const clone = copy !== 'home' + return ( + <> + {cards.map((card, index) => ( +
+ {card} +
+ ))} + + ) +} + +interface FeaturesRailProps { + /** Accessible name of the scrolling region. */ + label: string + /** + * The cards, in order. Each becomes one slot; once JS runs the whole set is + * cloned on both sides so the rail loops. + */ + children: ReactNode +} + +/** + * The homepage product rail: native horizontal scrolling that never ends. + * + * The server renders the set once, so the HTML - and any visit without JS - is + * the plain finite rail with the first card under the heading. After hydration + * the set is cloned once on each side, the scroll position jumps one set width + * before paint so nothing visibly moves (folded, so Strict Mode's second run of + * the effect lands on the same spot), and a passive scroll listener folds the + * position back into the middle copy whenever it drifts half a set past it. The fold is an exact set width onto identical content, so the space + * left of the first card is always the tail of the set and scrolling in either + * direction never meets an end. + * + * Wheel, trackpad, touch, and keyboard all drive the native scroller with no + * snapping to fight them, so the rail scrolls the same whether or not the + * pointer is over a card. A mouse can also drag the rail: past a small + * threshold the press scrolls by the pointer's movement (as deltas, so a fold + * mid-drag is harmless), the click that would follow is swallowed, and native + * link and image drags are off. Clones stay clickable but sit outside the tab + * order and the accessibility tree, so keyboard and screen-reader users meet + * each product exactly once. + * + * Both screen edges blur and fade into the page ground ({@link EdgeFade}), the + * same treatment the product-demo stage wears, so a card leaving the rail + * softens away instead of being cut off at the viewport. + */ +export function FeaturesRail({ label, children }: FeaturesRailProps) { + const railRef = useRef(null) + const setWidthRef = useRef(0) + const [looping, setLooping] = useState(false) + const cards = Children.toArray(children) + + useEffect(() => { + setLooping(true) + }, []) + + useLayoutEffect(() => { + if (!looping) return + const rail = railRef.current + if (!rail) return + + const measure = () => { + const lead = rail.querySelector('[data-copy="lead"]') + const home = rail.querySelector('[data-copy="home"]') + setWidthRef.current = lead && home ? home.offsetLeft - lead.offsetLeft : 0 + } + const fold = () => { + const next = foldScrollLeft(rail.scrollLeft, setWidthRef.current) + if (Math.abs(next - rail.scrollLeft) > FOLD_TOLERANCE) rail.scrollLeft = next + } + + for (const link of rail.querySelectorAll( + '[data-copy="lead"] a, [data-copy="tail"] a' + )) { + link.tabIndex = -1 + } + measure() + rail.scrollLeft = foldScrollLeft(rail.scrollLeft + setWidthRef.current, setWidthRef.current) + rail.addEventListener('scroll', fold, { passive: true }) + const observer = + typeof ResizeObserver === 'undefined' + ? undefined + : new ResizeObserver(() => { + measure() + fold() + }) + observer?.observe(rail) + + return () => { + rail.removeEventListener('scroll', fold) + observer?.disconnect() + } + }, [looping]) + + useEffect(() => { + const rail = railRef.current + if (!rail) return + let pointerId: number | null = null + let startX = 0 + let lastX = 0 + let dragged = false + + const onPointerDown = (event: PointerEvent) => { + if (event.pointerType !== 'mouse' || event.button !== 0) return + pointerId = event.pointerId + startX = event.clientX + lastX = event.clientX + dragged = false + } + const onPointerMove = (event: PointerEvent) => { + if (event.pointerId !== pointerId) return + if (!dragged) { + if (Math.abs(event.clientX - startX) < DRAG_THRESHOLD_PX) return + dragged = true + rail.dataset.dragging = '' + rail.setPointerCapture?.(event.pointerId) + } + rail.scrollLeft -= event.clientX - lastX + lastX = event.clientX + } + const onPointerEnd = (event: PointerEvent) => { + if (event.pointerId !== pointerId) return + pointerId = null + delete rail.dataset.dragging + if (rail.hasPointerCapture?.(event.pointerId)) rail.releasePointerCapture(event.pointerId) + } + const onClick = (event: MouseEvent) => { + if (!dragged) return + dragged = false + event.preventDefault() + event.stopPropagation() + } + const onDragStart = (event: DragEvent) => event.preventDefault() + + rail.addEventListener('pointerdown', onPointerDown) + rail.addEventListener('pointermove', onPointerMove) + rail.addEventListener('pointerup', onPointerEnd) + rail.addEventListener('pointercancel', onPointerEnd) + rail.addEventListener('click', onClick, true) + rail.addEventListener('dragstart', onDragStart) + return () => { + rail.removeEventListener('pointerdown', onPointerDown) + rail.removeEventListener('pointermove', onPointerMove) + rail.removeEventListener('pointerup', onPointerEnd) + rail.removeEventListener('pointercancel', onPointerEnd) + rail.removeEventListener('click', onClick, true) + rail.removeEventListener('dragstart', onDragStart) + } + }, []) + + return ( +
+
+ {looping && } + + {looping && } +
+
+ +
+
+ ) +} diff --git a/apps/sim/app/(landing)/components/features/components/features-rail/index.ts b/apps/sim/app/(landing)/components/features/components/features-rail/index.ts new file mode 100644 index 00000000000..8283070c3a5 --- /dev/null +++ b/apps/sim/app/(landing)/components/features/components/features-rail/index.ts @@ -0,0 +1 @@ +export { FeaturesRail } from './features-rail' diff --git a/apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx b/apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx index 75660444a55..4763bb1b9ea 100644 --- a/apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx +++ b/apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx @@ -18,13 +18,12 @@ import { CapturedPlatformSurface } from '@/app/(landing)/components/features/com * approximated, then rounded up to the worst-case (peak render/viewport * ratio) in each tier so the browser never under-fetches: * `callout = 1.25 * (viewport - 2*gutter - 32px card padding - [40px gap + - * 386px fixed copy column, desktop only])`, gutter = `px-20`/`max-lg:px-8`/ - * `max-sm:px-5` from `Features`'s grid, matching `FeatureCard`'s - * `max-lg:grid-cols-1` stack. Peak ratios (verified against a static - * reproduction of this exact layout rendered at each Tailwind breakpoint): - * ~113.3% at the `max-width: 1023px` stacked tier's own upper edge, ~108.6% - * at `1460px` (the container's cap, where render width stops growing with - * viewport - hence the final tier is a flat px value, not a vw fraction). + * 386px fixed copy column, desktop only])`. Gutter is the shared landing + * gutter. Peak ratios (verified against a static reproduction of this exact + * layout rendered at each Tailwind breakpoint): ~113.3% at the + * `max-width: 1023px` stacked tier's own upper edge, ~108.6% at `1728px` + * (the container's cap, where render width stops growing with viewport - + * hence the final tier is a flat px value, not a vw fraction). */ export function IntegrationsCallout() { return ( @@ -35,7 +34,7 @@ export function IntegrationsCallout() { > diff --git a/apps/sim/app/(landing)/components/features/features.test.tsx b/apps/sim/app/(landing)/components/features/features.test.tsx new file mode 100644 index 00000000000..3aa3551e0b8 --- /dev/null +++ b/apps/sim/app/(landing)/components/features/features.test.tsx @@ -0,0 +1,31 @@ +/** + * @vitest-environment node + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { Features } from '@/app/(landing)/components/features/features' + +const MODULES = [ + { title: 'Chat', href: '/signup' }, + { title: 'Workflows', href: '/workflows' }, + { title: 'Knowledge Base', href: '/knowledge' }, + { title: 'Tables', href: '/tables' }, + { title: 'Files', href: '/files' }, + { title: 'Logs', href: '/logs' }, +] as const + +describe('Features', () => { + it('renders every core Sim module as a crawlable tall card', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('Everything AI agents need to do real work') + expect(html).toContain('aspect-[5/6]') + expect(html).toContain('rounded-[12px]') + expect(html).toContain('overflow-x-auto') + + for (const module of MODULES) { + expect(html).toContain(`>${module.title}`) + expect(html).toContain(`href="${module.href}"`) + } + }) +}) diff --git a/apps/sim/app/(landing)/components/features/features.tsx b/apps/sim/app/(landing)/components/features/features.tsx index 498ab5b2ced..185e0e8e4ee 100644 --- a/apps/sim/app/(landing)/components/features/features.tsx +++ b/apps/sim/app/(landing)/components/features/features.tsx @@ -1,94 +1,99 @@ -import { BuildCallout } from '@/app/(landing)/components/features/components/build-callout' -import { FeatureCard } from '@/app/(landing)/components/features/components/feature-card' -import { IntegrationsCallout } from '@/app/(landing)/components/features/components/integrations-callout/integrations-callout' -import { KnowledgeCallout } from '@/app/(landing)/components/features/components/knowledge-callout/knowledge-callout' -import { LogsCallout } from '@/app/(landing)/components/features/components/logs-callout' +import { cn } from '@sim/emcn' +import { CoreFeatureCard } from '@/app/(landing)/components/features/components/core-feature-card' +import { FeaturesRail } from '@/app/(landing)/components/features/components/features-rail' +import { + HOME_INSET, + HOME_TYPE, + LANDING_CONTENT_WIDTH, + LANDING_GUTTER, +} from '@/app/(landing)/components/landing-layout' +import { BuildMethodsGraphic } from '@/app/(landing)/enterprise/components/feature-graphics' +import { FileLibraryGraphic } from '@/app/(landing)/files/components/feature-graphics/file-library-graphic' +import { ConnectorSyncGraphic } from '@/app/(landing)/knowledge/components/feature-graphics/connector-sync-graphic' +import { RunTraceGraphic } from '@/app/(landing)/logs/components/feature-graphics' +import { TableGridGraphic } from '@/app/(landing)/tables/components/feature-graphics' +import { WorkflowCanvasGraphic } from '@/app/(landing)/workflows/components/feature-graphics' /** - * Landing features - how Sim works, as a platform lifecycle. Four beats, in the - * order you actually use Sim: bring your tools in (Integrate), give it data to - * reason over (Context), build the agent logic (Build), then watch it run - * (Monitor). Each beat is a Cursor-style {@link FeatureCard}: one large - * outlined card holding a media stage (backdrop painting + elevated real-UI - * callout) and a copy column, with the media side alternating card to card. - * - * The section's `

` is `sr-only` - each beat carries its own visible `

`, - * so the section heading exists only to anchor the heading hierarchy and give AI - * crawlers an atomic summary. - * - * Inter-section spacing is owned by the `
` flex `gap` in `landing.tsx`; - * this section carries no vertical padding. The section itself is FULL-WIDTH so - * its bottom rule can bleed to the browser edges; the card grid inside carries - * the shared gutter (`px-20`) and the `max-w-[1460px]` cap. The last card - * squares its bottom corners (`flushBottom`) and sits exactly on the rule, so - * its outline merges into the full-bleed divider. - * - * The cards stack in a single column at every width on a 112px rhythm - * (matching Cursor's spacing between feature cards). Below `lg` each card - * internally reflows media-over-copy. - * - * Per-beat icons are still abstract placeholders (text eyebrows); distinct - * abstract glyphs land in a later pass. + * Homepage product suite - six tall editorial modules built from the same + * product-faithful UI illustrations used across Sim's platform pages. + */ +const CORE_FEATURES = [ + { + title: 'Chat', + description: 'Talk to Sim to build and manage agents in natural language.', + href: '/signup', + tone: 'mid', + visual: , + }, + { + title: 'Workflows', + description: 'Sim connects blocks, models, and integrations into agent logic.', + href: '/workflows', + tone: 'light', + visual: , + }, + { + title: 'Knowledge Base', + description: 'Sim gives every agent trusted memory from synced sources.', + href: '/knowledge', + tone: 'mid', + visual: , + }, + { + title: 'Tables', + description: 'Sim stores the structured data agents read and update between runs.', + href: '/tables', + tone: 'light', + visual: , + }, + { + title: 'Files', + description: 'Sim keeps team uploads and agent outputs in one shared store.', + href: '/files', + tone: 'mid', + visual: , + }, + { + title: 'Logs', + description: 'Sim traces every agent run block by block, including failures.', + href: '/logs', + tone: 'light', + visual: , + }, +] as const + +/** + * Six product modules on one horizontally scrolling rail. The heading keeps + * the page measure; the rail runs flush to the screen edges and loops - see + * {@link FeaturesRail}. The section is the rail's size container. */ export function Features() { return ( -
-

- Integrate your tools, give Sim context, build agents, and monitor every run. -

- -
- {/* Integrate: bring your stack in. */} - - - +
+
+
+

+ Everything AI agents need to do real work +

- {/* Context: store data semantically. */} - - - - - {/* Build: wire agent logic in the visual builder. */} - - - - - {/* Monitor: watch every run. */} - - - + + {CORE_FEATURES.map((feature) => ( + + ))} + +
- - {/* Full-bleed rule the last card's squared bottom edge merges into - - spans the whole browser, past the content cap and gutter (the section - itself is full-width; only the card grid above is capped). */} -
) } diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx new file mode 100644 index 00000000000..a39fb279919 --- /dev/null +++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx @@ -0,0 +1,128 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WORDMARK_PATHS } from '@/lib/branding/wordmark' +import { FooterWordmarkLoop } from '@/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop' + +const SHAPES = ['metaballs', 'relay', 'compass', 'corners', 'burst', 'squeeze', 'thinking'] as const + +/** One full pass of the master film, in ms. */ +const CYCLE_MS = 17_100 + +let pending: FrameRequestCallback[] = [] +let clock = 0 +let root: Root | null = null +let host: HTMLDivElement | null = null + +/** + * Drives the captured frame callbacks to `ms` on the loop's own clock in 50ms + * steps - under the loop's 100ms per-frame cap, so no choreography is skipped. + */ +function advanceTo(ms: number): void { + while (clock < ms) { + clock = Math.min(clock + 50, ms) + const frame = pending.shift() + if (!frame) throw new Error('the loop stopped requesting frames') + const now = clock + act(() => frame(now)) + } +} + +function attr(selector: string, name: string): string | null { + return host?.querySelector(selector)?.getAttribute(name) ?? null +} + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + pending = [] + clock = 0 + const stubs = { + requestAnimationFrame: (cb: FrameRequestCallback) => pending.push(cb), + cancelAnimationFrame: () => { + pending = [] + }, + matchMedia: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + }), + } + for (const [name, value] of Object.entries(stubs)) { + vi.stubGlobal(name, value) + Object.assign(window, { [name]: value }) + } + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + act(() => root?.render()) + const anchor = pending.shift() + if (!anchor) throw new Error('the loop never started') + act(() => anchor(0)) +}) + +afterEach(() => { + act(() => root?.unmount()) + root = null + host?.remove() + host = null + vi.unstubAllGlobals() +}) + +describe('FooterWordmarkLoop', () => { + it('server-renders the crisp wordmark as the resting frame', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('aria-hidden="true"') + expect(html).toContain('data-stage="wm" opacity="1"') + expect(html).toContain('data-stage="orb" opacity="0"') + expect(html).toContain('stdDeviation="0.55"') + for (const shape of SHAPES) { + expect(html).toContain(`data-stage="${shape}" opacity="0"`) + } + for (const d of WORDMARK_PATHS) { + expect(html).toContain(`d="${d}"`) + } + }) + + it('plays the master timeline: wordmark, orb, the seven shapes, orb, wordmark', () => { + expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550') + + advanceTo(2700) + expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000') + expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000') + expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000') + + advanceTo(3900) + expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('1.0000') + expect(attr('[data-stage="orb"]', 'opacity')).toBe('0.0000') + expect(attr('[data-anim="metaballsA"]', 'transform')).not.toBe('translate(0.000 0.000)') + + advanceTo(7000) + expect(attr('[data-stage="compass"]', 'opacity')).toBe('1.0000') + expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('0.0000') + + advanceTo(9700) + expect(attr('[data-stage="burst"]', 'opacity')).toBe('1.0000') + + advanceTo(16000) + expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') + expect(attr('[data-stage="thinking"]', 'opacity')).toBe('0.0000') + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550') + + advanceTo(CYCLE_MS + 2700) + expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000') + expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000') + }) + + it('stops requesting frames on unmount', () => { + advanceTo(500) + act(() => root?.unmount()) + root = null + expect(pending).toHaveLength(0) + }) +}) diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx new file mode 100644 index 00000000000..4d919051653 --- /dev/null +++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx @@ -0,0 +1,637 @@ +'use client' + +import { type CSSProperties, useEffect, useId, useRef } from 'react' +import { cn } from '@sim/emcn' +import { WORDMARK_PATHS, WORDMARK_VIEW_BOX } from '@/lib/branding/wordmark' + +/** + * The mark's ink: the platform's thinking-loader gradient tokens, so it follows + * the theme. Light resolves to the locked `GOO_GRADIENT` recipe; dark lifts it + * to the loader's light-on-dark material. Set through `style`, where `var()` + * is unambiguous, rather than as a presentation attribute. + */ +const INK_STOP_INNER = { stopColor: 'var(--thinking-ink-inner)' } as const satisfies CSSProperties +const INK_STOP_OUTER = { stopColor: 'var(--thinking-ink-outer)' } as const satisfies CSSProperties + +/** + * The seven thinking-loader shapes in play order, each with its hold in ms. + * Order, holds, and every timing constant below are the master's - the + * "Sim loader wordmark" film's deterministic generator - so the footer plays + * the same choreography frame for frame. + */ +const SHAPES = [ + ['metaballs', 2000], + ['relay', 1300], + ['compass', 2000], + ['corners', 800], + ['burst', 1300], + ['squeeze', 1200], + ['thinking', 2000], +] as const + +type ShapeKey = (typeof SHAPES)[number][0] +type StageKey = ShapeKey | 'orb' | 'wm' + +/** Crossfade between two consecutive shapes. */ +const MORPH = 500 +/** Liquid morph between the wordmark and the orb, on each side of the cycle. */ +const MORPH_LOGO = 1200 +/** Opening hold on the crisp wordmark. */ +const LOGO_HOLD = 1300 +/** Orb settle on either side of the shape cycle. */ +const ORB_BEAT = 450 +/** Closing hold on the wordmark before the loop wraps back to the opening hold. */ +const HOLD_LOGO_END = 1700 +const TAIL = 200 +/** Goo blur while liquid (through the cycle) and while crisp (the wordmark). */ +const GOO_HI = 5 +const GOO_LO = 0.55 +/** + * Shapes that restart from compact when they appear and play exactly one pulse + * of this many ms (just under a loop, so the dots reach the edge without + * snapping back) instead of free-running on the shared clock. + */ +const LOCAL_PULSE: Partial> = { burst: 770 } +/** + * Longest step the clock advances per frame. A background tab or a stalled + * frame resumes mid-choreography instead of skipping ahead. + */ +const MAX_FRAME_STEP = 100 + +const T_LOGO_HOLD_END = LOGO_HOLD +const T_INTRO_END = T_LOGO_HOLD_END + MORPH_LOGO +const FIRST_SHAPE_START = T_INTRO_END + ORB_BEAT + +/** Lays the shapes end to end from the first shape's start. */ +function buildShapeWindows(): { + windows: Record + end: number +} { + const windows: Partial> = {} + let cursor = FIRST_SHAPE_START + for (const [key, hold] of SHAPES) { + windows[key] = [cursor, cursor + hold] + cursor += hold + } + return { windows: windows as Record, end: cursor } +} + +const { windows: SHAPE_WINDOWS, end: SHAPES_END } = buildShapeWindows() +const T_OUTRO_START = SHAPES_END + ORB_BEAT +const T_OUTRO_END = T_OUTRO_START + MORPH_LOGO +/** One full pass: wordmark → orb → seven shapes → orb → wordmark. */ +const CYCLE_MS = T_OUTRO_END + HOLD_LOGO_END + TAIL + +interface Track { + /** Period in ms. */ + dur: number + /** Play backwards on odd iterations. */ + alt?: boolean + /** Linear timing instead of the CSS default ease. */ + lin?: boolean + /** `[progress, x, y]` keyframes in stage units. */ + stops: ReadonlyArray + /** Optional `[progress, opacity]` keyframes. */ + op?: ReadonlyArray +} + +/** Per-element motion tracks - the loader's CSS keyframes, evaluated analytically. */ +const TRACKS = { + metaballsA: { + dur: 1000, + alt: true, + stops: [ + [0, 0, 0], + [0.9, 28, 0], + [1, 28, 0], + ], + }, + metaballsB: { + dur: 1000, + alt: true, + stops: [ + [0, 0, 0], + [0.9, -28, 0], + [1, -28, 0], + ], + }, + relayBall: { + dur: 1300, + lin: true, + stops: [ + [0, 0, 0], + [1, 58, 0], + ], + op: [ + [0, 0], + [0.2, 1], + [0.78, 1], + [1, 0], + ], + }, + compassMover: { + dur: 2000, + stops: [ + [0, 0, 0], + [0.25, 27, 27], + [0.5, 0, 54], + [0.75, -27, 27], + [1, 0, 0], + ], + }, + cornersA: { + dur: 800, + stops: [ + [0, 0, 0], + [1, 46, 0], + ], + }, + cornersB: { + dur: 800, + stops: [ + [0, 0, 0], + [1, 0, 46], + ], + }, + cornersC: { + dur: 800, + stops: [ + [0, 0, 0], + [1, -46, 0], + ], + }, + cornersD: { + dur: 800, + stops: [ + [0, 0, 0], + [1, 0, -46], + ], + }, + burstUp: { + dur: 800, + stops: [ + [0, 0, 0], + [1, 0, -50], + ], + }, + burstDown: { + dur: 800, + stops: [ + [0, 0, 0], + [1, 0, 50], + ], + }, + burstLeft: { + dur: 800, + stops: [ + [0, 0, 0], + [1, -50, 0], + ], + }, + burstRight: { + dur: 800, + stops: [ + [0, 0, 0], + [1, 50, 0], + ], + }, + squeezeBarL: { + dur: 600, + alt: true, + stops: [ + [0, 0, 0], + [0.3, 0, 0], + [1, 10, 0], + ], + }, + squeezeBarR: { + dur: 600, + alt: true, + stops: [ + [0, 0, 0], + [0.3, 0, 0], + [1, -10, 0], + ], + }, + thinkA: { + dur: 1600, + alt: true, + stops: [ + [0, 0, 0], + [1, -20, -14], + ], + }, + thinkB: { + dur: 1900, + alt: true, + stops: [ + [0, 0, 0], + [1, 22, -10], + ], + }, + thinkC: { + dur: 1300, + alt: true, + stops: [ + [0, 0, 0], + [1, 2, 22], + ], + }, +} as const satisfies Record + +type AnimKey = keyof typeof TRACKS + +const clamp01 = (x: number): number => Math.max(0, Math.min(1, x)) + +/** A CSS `cubic-bezier` timing function, solved for `x` by Newton iteration. */ +function cubicBezier( + p1x: number, + p1y: number, + p2x: number, + p2y: number +): (progress: number) => number { + const cx = 3 * p1x + const bx = 3 * (p2x - p1x) - cx + const ax = 1 - cx - bx + const cy = 3 * p1y + const by = 3 * (p2y - p1y) - cy + const ay = 1 - cy - by + const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t + const sampleY = (t: number) => ((ay * t + by) * t + cy) * t + const slopeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx + return (progress) => { + let t = progress + for (let i = 0; i < 8; i++) { + const error = sampleX(t) - progress + if (Math.abs(error) < 1e-6) break + const slope = slopeX(t) + if (Math.abs(slope) < 1e-6) break + t -= error / slope + } + return sampleY(clamp01(t)) + } +} + +/** The CSS default `ease`. */ +const EASE = cubicBezier(0.25, 0.1, 0.25, 1) +const LINEAR = (progress: number): number => progress + +/** Hermite smoothstep from `a` to `b`. */ +function smooth(a: number, b: number, x: number): number { + const t = clamp01((x - a) / (b - a)) + return t * t * (3 - 2 * t) +} + +/** Opacity envelope: rises over `[t0, t1]`, holds, falls over `[t2, t3]`. */ +function envelope(t: number, t0: number, t1: number, t2: number, t3: number): number { + return Math.min(smooth(t0, t1, t), 1 - smooth(t2, t3, t)) +} + +/** The keyframe pair around `frac` and the clamped progress between them. */ +function segment( + stops: ReadonlyArray, + frac: number +): { a: T; b: T; p: number } { + let a = stops[0] + let b = stops[stops.length - 1] + for (let i = 0; i < stops.length - 1; i++) { + if (frac >= stops[i][0] && frac <= stops[i + 1][0]) { + a = stops[i] + b = stops[i + 1] + break + } + } + const span = b[0] - a[0] || 1 + return { a, b, p: clamp01((frac - a[0]) / span) } +} + +interface Sample { + x: number + y: number + opacity: number +} + +/** Position and opacity of a track at `at` ms on its own clock. */ +function sampleTrack(track: Track, at: number): Sample { + const raw = at / track.dur + const iteration = Math.floor(raw) + let frac = raw - iteration + if (track.alt && iteration % 2 === 1) frac = 1 - frac + const { a, b, p } = segment(track.stops, frac) + const eased = (track.lin ? LINEAR : EASE)(p) + const x = a[1] + (b[1] - a[1]) * eased + const y = a[2] + (b[2] - a[2]) * eased + let opacity = 1 + if (track.op) { + const o = segment(track.op, frac) + opacity = o.a[1] + (o.b[1] - o.a[1]) * o.p + } + return { x, y, opacity } +} + +/** + * The wordmark's width inside the 100-unit stage - the generator's 816×392 + * mark at 0.11164, so the word sits at the same size relative to the orb. + */ +const WORDMARK_WIDTH = 91.1 +const WORDMARK_SCALE = WORDMARK_WIDTH / WORDMARK_VIEW_BOX.width +const WORDMARK_HEIGHT = WORDMARK_VIEW_BOX.height * WORDMARK_SCALE +const WORDMARK_X = (100 - WORDMARK_WIDTH) / 2 +const WORDMARK_Y = (100 - WORDMARK_HEIGHT) / 2 +const WORDMARK_CX = WORDMARK_VIEW_BOX.width / 2 +const WORDMARK_CY = WORDMARK_VIEW_BOX.height / 2 + +const round = (n: number): string => n.toFixed(3) + +interface AnimatedNode { + el: SVGGraphicsElement + track: Track + stage: ShapeKey +} + +interface StageNode { + el: SVGGElement + key: StageKey +} + +/** + * Paints one frame of the choreography at `t` ms into the cycle by writing + * SVG attributes directly - no React render per frame. + */ +function paintFrame( + t: number, + blur: SVGFEGaussianBlurElement, + stages: StageNode[], + anims: AnimatedNode[] +): void { + for (const { el, track, stage } of anims) { + const pulse = LOCAL_PULSE[stage] + const clock = + pulse === undefined + ? t + : Math.max(0, Math.min(t - (SHAPE_WINDOWS[stage][0] + MORPH / 2), pulse)) + const sample = sampleTrack(track, clock) + el.setAttribute('transform', `translate(${round(sample.x)} ${round(sample.y)})`) + if (track.op) el.setAttribute('opacity', sample.opacity.toFixed(4)) + } + + const introLogo = 1 - smooth(T_LOGO_HOLD_END, T_INTRO_END, t) + const outroLogo = smooth(T_OUTRO_START, T_OUTRO_END, t) + const logo = Math.max(introLogo, outroLogo) + const orbIn = envelope( + t, + T_LOGO_HOLD_END, + T_INTRO_END, + FIRST_SHAPE_START - MORPH / 2, + FIRST_SHAPE_START + MORPH / 2 + ) + const orbOut = envelope( + t, + SHAPES_END - MORPH / 2, + SHAPES_END + MORPH / 2, + T_OUTRO_START, + T_OUTRO_END + ) + const orb = Math.max(orbIn, orbOut) + + for (const { el, key } of stages) { + let opacity: number + if (key === 'wm') opacity = logo + else if (key === 'orb') opacity = orb + else { + const [start, end] = SHAPE_WINDOWS[key] + opacity = envelope(t, start - MORPH / 2, start + MORPH / 2, end - MORPH / 2, end + MORPH / 2) + } + el.setAttribute('opacity', opacity.toFixed(4)) + } + + const liquid = Math.min( + smooth(T_LOGO_HOLD_END, T_INTRO_END, t), + 1 - smooth(T_OUTRO_START, T_OUTRO_END, t) + ) + const deviation = round(GOO_LO + (GOO_HI - GOO_LO) * liquid) + if (blur.getAttribute('stdDeviation') !== deviation) blur.setAttribute('stdDeviation', deviation) +} + +interface FooterWordmarkLoopProps { + /** Layout only - margins and alignment. The mark owns its size and chrome. */ + className?: string +} + +/** + * The footer's closing brand beat: the "Sim loader wordmark" film, live. The + * crisp `sim` wordmark goo-melts into the thinking loader's orb, cycles through + * all seven loader shapes with gooey morphs between them, settles back to the + * orb, and liquid-morphs back into the wordmark - then holds and repeats. + * Same geometry, goo filter, ink gradient, and timeline as the master + * generator and the product's `ThinkingLoader`, drawn in one 100-unit SVG + * stage that scales with the viewport. + * + * The stage reserves a 5:3 slot rather than its full square: the crisp wordmark + * only ever needs the middle band, and the orb and shapes overflow symmetrically + * into the beat's margins while they play, so the footer stays short enough to + * sit on one screen together with its link directory. + * + * Rendering budget: the server renders the resting frame (crisp wordmark) so + * the mark is in the HTML with zero layout shift and needs no JS to look + * finished. The `requestAnimationFrame` loop only runs while the stage is in + * the viewport, writes attributes straight to the SVG (no React render per + * frame), and never starts under `prefers-reduced-motion`, where the wordmark + * simply stays put. + */ +export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { + const svgRef = useRef(null) + const id = useId().replace(/[^a-zA-Z0-9-]/g, '') + const gooId = `fwl-goo-${id}` + const inkId = `fwl-ink-${id}` + const wordmarkInkId = `fwl-wm-ink-${id}` + const clipId = `fwl-clip-${id}` + const windowId = `fwl-window-${id}` + + useEffect(() => { + const svg = svgRef.current + if (!svg) return + const blur = svg.querySelector('[data-goo]') + if (!blur) return + + const stages: StageNode[] = Array.from( + svg.querySelectorAll('[data-stage]'), + (el) => ({ el, key: el.getAttribute('data-stage') as StageKey }) + ) + const anims: AnimatedNode[] = [] + for (const el of svg.querySelectorAll('[data-anim]')) { + const stage = el.closest('[data-stage]')?.getAttribute('data-stage') as ShapeKey | undefined + if (stage) anims.push({ el, track: TRACKS[el.getAttribute('data-anim') as AnimKey], stage }) + } + + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)') + let frame: number | null = null + let previous: number | null = null + let elapsed = 0 + let inView = false + + const tick = (now: number) => { + if (previous !== null) elapsed += Math.min(now - previous, MAX_FRAME_STEP) + previous = now + paintFrame(elapsed % CYCLE_MS, blur, stages, anims) + frame = requestAnimationFrame(tick) + } + const play = () => { + if (frame !== null || !inView || reducedMotion?.matches) return + previous = null + frame = requestAnimationFrame(tick) + } + const pause = () => { + if (frame === null) return + cancelAnimationFrame(frame) + frame = null + } + const onMotionPreference = () => { + if (reducedMotion?.matches) { + pause() + elapsed = 0 + paintFrame(0, blur, stages, anims) + } else { + play() + } + } + reducedMotion?.addEventListener('change', onMotionPreference) + + let observer: IntersectionObserver | undefined + if (typeof IntersectionObserver === 'undefined') { + inView = true + play() + } else { + observer = new IntersectionObserver(([entry]) => { + inView = entry.isIntersecting + if (inView) play() + else pause() + }) + observer.observe(svg) + } + + return () => { + pause() + observer?.disconnect() + reducedMotion?.removeEventListener('change', onMotionPreference) + } + }, []) + + return ( +
+ +
+ ) +} diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/index.ts b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/index.ts new file mode 100644 index 00000000000..0df08ea7389 --- /dev/null +++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/index.ts @@ -0,0 +1 @@ +export { FooterWordmarkLoop } from './footer-wordmark-loop' diff --git a/apps/sim/app/(landing)/components/footer/components/theme-toggle/index.ts b/apps/sim/app/(landing)/components/footer/components/theme-toggle/index.ts new file mode 100644 index 00000000000..694671e5d77 --- /dev/null +++ b/apps/sim/app/(landing)/components/footer/components/theme-toggle/index.ts @@ -0,0 +1 @@ +export { ThemeToggle } from './theme-toggle' diff --git a/apps/sim/app/(landing)/components/footer/components/theme-toggle/theme-toggle.tsx b/apps/sim/app/(landing)/components/footer/components/theme-toggle/theme-toggle.tsx new file mode 100644 index 00000000000..15bc6a57c9a --- /dev/null +++ b/apps/sim/app/(landing)/components/footer/components/theme-toggle/theme-toggle.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useId, useSyncExternalStore } from 'react' +import { cn } from '@sim/emcn' +import { Moon, Sun } from '@sim/emcn/icons' +import { useTheme } from 'next-themes' + +/** + * The two themes on offer, in reading order. `system` is deliberately absent: + * the marketing site is light by design and dark is an explicit choice, so the + * control is a plain either/or. + */ +const OPTIONS = [ + { value: 'light', label: 'Light theme', Icon: Sun }, + { value: 'dark', label: 'Dark theme', Icon: Moon }, +] as const + +type ThemeOption = (typeof OPTIONS)[number]['value'] + +const SEGMENT = + 'flex size-[22px] items-center justify-center rounded-full transition-colors duration-150 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-[var(--text-secondary)] peer-focus-visible:outline-offset-2' + +/** + * The selected segment is painted by the theme class on ``, not by React + * state: next-themes puts that class on the document before first paint, so + * the right segment already reads as selected in the server HTML and never + * flips after hydration. Each segment carries both states and the `dark:` pair + * picks one. + */ +const SEGMENT_TONE: Record = { + light: + 'bg-[var(--surface-active)] text-[var(--text-primary)] dark:bg-transparent dark:text-[var(--text-muted)] dark:hover-hover:text-[var(--text-primary)]', + dark: 'text-[var(--text-muted)] hover-hover:text-[var(--text-primary)] dark:bg-[var(--surface-active)] dark:text-[var(--text-primary)]', +} + +const subscribeToNothing = () => () => {} + +/** + * `true` once hydrated, `false` in server HTML and during hydration - the + * store-backed form React reconciles without a mismatch, unlike an effect that + * flips state after mount. + */ +function useHydrated(): boolean { + return useSyncExternalStore( + subscribeToNothing, + () => true, + () => false + ) +} + +/** + * Footer light/dark switch: a hairline pill holding a sun and a moon, with the + * current theme's segment filled. Picking a segment writes the visitor's + * choice through next-themes (`sim-theme`), the same store the workspace + * reads, so the theme carries from the marketing site into the app. + * + * Native radios provide a single tab stop and arrow-key selection. Checked + * state waits for hydration because the server cannot read the stored theme; + * the visual selection already follows the document theme before hydration. + */ +export function ThemeToggle() { + const groupName = useId() + const { resolvedTheme, setTheme } = useTheme() + const hydrated = useHydrated() + + return ( +
+ {OPTIONS.map(({ value, label, Icon }) => ( + + ))} +
+ ) +} diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index 5c74a5a7e8f..d7ba75d8797 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -1,6 +1,10 @@ +import { cn } from '@sim/emcn' import Link from 'next/link' import { ConsentPreferencesTrigger } from '@/app/_shell/consent/consent-preferences-trigger' import { ALL_COMPETITORS } from '@/app/(landing)/comparisons/utils' +import { FooterWordmarkLoop } from '@/app/(landing)/components/footer/components/footer-wordmark-loop' +import { ThemeToggle } from '@/app/(landing)/components/footer/components/theme-toggle' +import { LANDING_CONTENT_WIDTH, LANDING_GUTTER } from '@/app/(landing)/components/landing-layout' import { SimWordmark } from '@/app/(landing)/components/navbar/components/sim-wordmark' import { MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils' @@ -12,11 +16,22 @@ import { MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils' * The closing CTA lives in its own {@link Cta} section above; this is purely the * `