mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(landing): fix Core Web Vitals regressions across public marketing pages (#5522)
* fix(landing): fix Core Web Vitals regressions across public marketing pages
- root layout unconditionally rendered next-runtime-env's PublicEnvScript,
which calls unstable_noStore() and silently forced every route in the app
dynamic - marketing pages never got static/ISR caching despite their own
revalidate. Gated it to self-hosted only; hosted now uses a static,
build-time equivalent (app/_shell/public-env-script.tsx)
- removed real pointer-drag handlers from the hero's decorative workflow
animation (was draggable despite being aria-hidden)
- disabled dragging/panning on the (currently unmounted) landing-preview
ReactFlow canvas so it's static-by-default if it's ever wired in
- lazy-mount the Product Demo section's duplicate HeroVisual instance via
next/dynamic + IntersectionObserver instead of loading it eagerly below
the fold
- disabled Next.js Link prefetch on always-in-viewport /signup and /login
CTAs (navbar, hero, mobile nav) so their JS isn't fetched on every
pageview regardless of whether the visitor clicks
- removed `unoptimized` from local blog/integration images (including the
priority LCP image on every blog post), letting next/image serve
resized AVIF/WebP instead of full-size originals
* fix(landing): address review findings on the CWV PR
- revert prefetch={false} on below-fold CTAs (cta.tsx, enterprise.tsx) -
contradicts the prefetch-on-approach rule this PR itself documents
- restore unoptimized on avatarUrl and the MDX body-image renderer, both of
which can legitimately hold external URLs outside next.config.ts's
image remotePatterns allow-list
- simplify handleAnchors to a single block argument now that positions are
static (the second "live position" argument was always identical to the
first after the drag-handler removal)
- extract the near-duplicate IntersectionObserver lazy-mount logic shared
by landing-preview-mount.tsx and product-demo-visual-mount.tsx into a
single apps/sim/app/(landing)/hooks/use-lazy-mount.ts hook
- import next-runtime-env's own exported PUBLIC_ENV_KEY constant instead of
a hardcoded string literal, and match its case-insensitive NEXT_PUBLIC_
filter exactly, removing any drift risk between the two implementations
- drop plain inline comments with no TSDoc home in favor of relying on the
existing TSDoc/CLAUDE.md documentation
* fix(landing): carry the unoptimized-image fix onto the shared content-*-page components
The blog/library split (#5516) moved the blog post/index/author JSX into
shared ContentPostPage/ContentIndexPage/ContentAuthorPage components while
this branch was in flight, so the original unoptimized removal (verified
local-only ogImage paths for both blog and library content) needs to land
on those shared components instead of the old per-route JSX.
* fix(landing): document ogImage's local-path expectation, fix CLAUDE.md structure doc
- add a one-line comment on ContentFrontmatterSchema.ogImage documenting
that it's rendered without unoptimized and expects a local path, matching
the existing avatarUrl comment convention (a reviewer noted ogImage's
schema is technically unconstrained and seo.ts has an http-prefix branch,
though all current content is local)
- add the new hooks/ folder to the (landing) CLAUDE.md structure diagram and
name use-lazy-mount.ts directly in the lazy-mount rule
* fix(landing): escape </script> breakout in the static public-env script
Greptile P1: a NEXT_PUBLIC_* value containing "</script>" would close the
inline script early and could inject markup/script into every hosted page.
Escape "<" in the serialized JSON before interpolating it, matching the
standard JSON-in-script-tag safeguard.
This commit is contained in:
@@ -34,8 +34,11 @@ Target: Lighthouse 95+ on mobile, LCP < 2.0s, CLS < 0.05, minimal hydration cost
|
||||
- **No heavy client libraries above the fold.** No animation frameworks (framer-motion etc.), no ReactFlow, no chart libs in the initial bundle. If a below-fold section truly needs one, load it with `next/dynamic` and a dimension-stable placeholder.
|
||||
- **Images via `next/image` always.** The LCP element (logo or hero visual) gets `priority`; everything below the fold lazy-loads (the default). Every image has explicit `width`/`height` - zero layout shift.
|
||||
- **Prefer CSS over JS.** Hover states, transitions, marquees, and reveal effects in CSS (`transition-*`, `animation`) rather than scroll listeners or animation libraries. Decorative motion respects `prefers-reduced-motion`.
|
||||
- **Static rendering.** The page is statically generated with `revalidate` (set in `page.tsx`). Never fetch per-request data in the page tree; anything dynamic (e.g. GitHub stars) is fetched at build/revalidate time on the server or deferred to a tiny client island.
|
||||
- **Static rendering.** The page is statically generated with `revalidate` (set in `page.tsx`). Never fetch per-request data in the page tree; anything dynamic (e.g. GitHub stars) is fetched at build/revalidate time or deferred to a client island. A `cookies()`/`headers()`/`unstable_noStore()` call anywhere in the tree - including the root `app/layout.tsx` - silently overrides every page's `revalidate` and forces the whole app dynamic. If a marketing page builds as `ƒ` instead of `○`/`●` (check `bun run build`'s route table), look upstream, not just at the page itself.
|
||||
- **Reserve space for everything.** Fixed dimensions or aspect ratios on all media, embeds, and async content. CLS budget is effectively zero.
|
||||
- **Decorative canvases and animations are non-interactive.** A hand-built product-demo animation or embedded ReactFlow canvas is presentation only - no drag handlers, no `nodesDraggable`/`panOnDrag`/`elementsSelectable` on ReactFlow. A visitor should never be able to click or drag a decorative element.
|
||||
- **Lazy-mount a heavy client island's second occurrence.** If the same animated component appears twice on a page, only the first (usually the hero) loads eagerly - the rest go through a small `'use client'` mount wrapper built on the shared `hooks/use-lazy-mount.ts` hook: `next/dynamic(..., { ssr: false })` gated by the hook's `IntersectionObserver`. See `components/product-demo/components/product-demo-visual-mount/` for the reference pattern, and `.claude/rules/sim-imports.md` for the barrel-cleanup step that must come with it.
|
||||
- **Don't prefetch authenticated-app routes from an always-visible CTA.** `<Link>` prefetches its target route's JS once it's in the viewport - a navbar/hero CTA to `/signup` or `/login` is always in view, so it downloads that route's bundle on every pageview. Pass `prefetch={false}` there. Leave the default on CTAs that only enter the viewport on scroll (prefetch-on-approach is the desired behavior there).
|
||||
|
||||
## SEO
|
||||
|
||||
@@ -72,6 +75,7 @@ Follow `.claude/rules/constitution.md` exactly: Sim is "the open-source AI works
|
||||
├── page.tsx # route entry: metadata + <Landing />
|
||||
├── landing.tsx # root composition: <main> section order
|
||||
├── workflows/ # a platform route: page.tsx (metadata) + workflows.tsx (config + shell)
|
||||
├── hooks/ # cross-page client hooks (useLazyMount, …) - bare files, no folder/barrel
|
||||
└── components/
|
||||
├── index.ts # top barrel
|
||||
├── navbar/{navbar.tsx, index.ts, components/<chip>/…} # <header><nav>: wordmark, dropdowns, stars, auth chips
|
||||
|
||||
@@ -55,7 +55,6 @@ export function ContentAuthorPage({
|
||||
width={600}
|
||||
height={315}
|
||||
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-[var(--text-muted)] text-xs'>
|
||||
|
||||
@@ -96,7 +96,6 @@ export function ContentIndexPage({
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
className='object-cover'
|
||||
priority={index < 3}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
@@ -158,7 +157,6 @@ export function ContentIndexPage({
|
||||
fill
|
||||
sizes='140px'
|
||||
className='object-cover'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -53,7 +53,6 @@ export function ContentPostPage({
|
||||
sizes='(max-width: 768px) 100vw, 450px'
|
||||
priority
|
||||
itemProp='image'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,7 +141,6 @@ export function ContentPostPage({
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
className='object-cover'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
|
||||
@@ -29,6 +29,7 @@ export function HeroCta() {
|
||||
</ChipLink>
|
||||
<ChipLink
|
||||
href='/signup'
|
||||
prefetch={false}
|
||||
className={cn(CTA_SIZE, 'border border-[var(--border-1)] max-sm:justify-center')}
|
||||
>
|
||||
Sign up
|
||||
|
||||
+4
-4
@@ -59,12 +59,12 @@ const CHROME_INTERIOR = { width: 1024, height: 721 } as const
|
||||
* chat|stage divider is covered by this region, so the divider users see is
|
||||
* the live `border-l`, appearing exactly as it does in the product.
|
||||
*
|
||||
* The chat pane stays `pointer-events-none` (decorative); the workflow pane is
|
||||
* interactive - blocks are draggable. Remounting the stage per cycle
|
||||
* (`key={cycleId}`) resets dragged positions and build state.
|
||||
* Both panes stay `pointer-events-none` (decorative, matching the hero's
|
||||
* `aria-hidden` frame) - blocks are static. Remounting the stage per cycle
|
||||
* (`key={cycleId}`) resets build state.
|
||||
*
|
||||
* Under `prefers-reduced-motion` the loop never starts: the finished exchange,
|
||||
* open stage, and fully-built workflow render statically (still draggable).
|
||||
* open stage, and fully-built workflow render statically.
|
||||
*/
|
||||
export function HeroPlatformLoop() {
|
||||
const regionRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
+13
-73
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { type CSSProperties, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { StageBlockCard } from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-block-card'
|
||||
import {
|
||||
@@ -22,43 +22,26 @@ interface HeroWorkflowStageProps {
|
||||
builtCount: number
|
||||
}
|
||||
|
||||
type Positions = Record<string, { x: number; y: number }>
|
||||
|
||||
const initialPositions = (): Positions =>
|
||||
Object.fromEntries(STAGE_BLOCKS.map((b) => [b.id, { x: b.x, y: b.y }]))
|
||||
|
||||
const STAGE_BLOCKS_BY_ID = new Map(STAGE_BLOCKS.map((b) => [b.id, b]))
|
||||
|
||||
/**
|
||||
* The hero window's live workflow canvas - the right-pane counterpart of the
|
||||
* chat loop. Blocks pop in one by one as `builtCount` advances (staggered
|
||||
* scale/fade entrances, edges stroke-draw once both endpoints exist), and every
|
||||
* block is DRAGGABLE: pointer-drag updates its position (scaled to design
|
||||
* space) and its edges follow live. The edge SVG is `overflow-visible` -
|
||||
* SVGs clip at their viewport by default, which cut the lines the moment a
|
||||
* dragged block left the design-canvas bounds while the HTML block cards
|
||||
* escaped freely. Positions reset when the parent remounts the stage for a
|
||||
* new loop pass (`key={cycleId}`).
|
||||
* scale/fade entrances, edges stroke-draw once both endpoints exist) at their
|
||||
* fixed positions. The edge SVG is `overflow-visible` - SVGs clip
|
||||
* at their viewport by default, which would cut the lines if a block ever sat
|
||||
* outside the design-canvas bounds.
|
||||
*
|
||||
* Decorative and `aria-hidden` (via the parent frame), so blocks are NOT
|
||||
* draggable - `pointer-events-none`, matching the rest of the hero animation.
|
||||
*
|
||||
* Blocks reuse the hero-visual's {@link WorkflowBlockContent} (the faithful
|
||||
* icon-tile + rows card body) in a card shell with vertical-flow handle nubs
|
||||
* (top in / bottom out), matching the real editor's vertical layout.
|
||||
*/
|
||||
export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
|
||||
const [positions, setPositions] = useState<Positions>(initialPositions)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [scale, setScale] = useState(MAX_STAGE_SCALE)
|
||||
const dragRef = useRef<{
|
||||
id: string
|
||||
pointerId: number
|
||||
startX: number
|
||||
startY: number
|
||||
originX: number
|
||||
originY: number
|
||||
/** Total design-px -> visual-px factor for the dragged block (this stage's
|
||||
* fit scale x the platform loop's design-space scale), captured at grab. */
|
||||
visualScale: number
|
||||
} | null>(null)
|
||||
|
||||
// Fit the design canvas to the card: scale down when the pane narrows so the
|
||||
// branch blocks never clip, capped at the full-width scale. Measures LAYOUT
|
||||
@@ -86,43 +69,6 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>, id: string) => {
|
||||
if (dragRef.current) return
|
||||
const pos = positions[id]
|
||||
dragRef.current = {
|
||||
id,
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
originX: pos.x,
|
||||
originY: pos.y,
|
||||
// Rendered width / design width = the block's total visual scale, all
|
||||
// ancestor transforms included - no need to thread each factor through.
|
||||
visualScale: e.currentTarget.getBoundingClientRect().width / BLOCK_WIDTH,
|
||||
}
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
},
|
||||
[positions]
|
||||
)
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || e.pointerId !== drag.pointerId || drag.visualScale <= 0) return
|
||||
const dx = (e.clientX - drag.startX) / drag.visualScale
|
||||
const dy = (e.clientY - drag.startY) / drag.visualScale
|
||||
setPositions((prev) => ({
|
||||
...prev,
|
||||
[drag.id]: { x: drag.originX + dx, y: drag.originY + dy },
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const onPointerEnd = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || e.pointerId !== drag.pointerId) return
|
||||
dragRef.current = null
|
||||
}, [])
|
||||
|
||||
const builtIds = useMemo(
|
||||
() => new Set(STAGE_BLOCKS.slice(0, builtCount).map((b) => b.id)),
|
||||
[builtCount]
|
||||
@@ -162,8 +108,8 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
|
||||
const target = STAGE_BLOCKS_BY_ID.get(to)
|
||||
if (!source || !target) return null
|
||||
const visible = builtIds.has(from) && builtIds.has(to)
|
||||
const s = handleAnchors(source, positions[from]).out
|
||||
const t = handleAnchors(target, positions[to]).in
|
||||
const s = handleAnchors(source).out
|
||||
const t = handleAnchors(target).in
|
||||
return (
|
||||
<path
|
||||
key={`${from}-${to}`}
|
||||
@@ -181,20 +127,14 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
|
||||
|
||||
{STAGE_BLOCKS.map((block) => {
|
||||
const built = builtIds.has(block.id)
|
||||
const pos = positions[block.id]
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
className={cn(
|
||||
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
|
||||
'transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
built ? 'scale-100 opacity-100' : 'pointer-events-none scale-[0.94] opacity-0'
|
||||
'pointer-events-none absolute transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
built ? 'scale-100 opacity-100' : 'scale-[0.94] opacity-0'
|
||||
)}
|
||||
style={{ left: pos.x, top: pos.y, width: BLOCK_WIDTH }}
|
||||
onPointerDown={(e) => onPointerDown(e, block.id)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerEnd}
|
||||
onPointerCancel={onPointerEnd}
|
||||
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
|
||||
>
|
||||
<StageBlockCard block={block} />
|
||||
</div>
|
||||
|
||||
@@ -120,10 +120,10 @@ export function verticalSmoothStep(sx: number, sy: number, tx: number, ty: numbe
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
/** Handle anchor points for a block at a live position. */
|
||||
export function handleAnchors(block: BlockDef, pos: { x: number; y: number }) {
|
||||
/** Handle anchor points for a block at its fixed position. */
|
||||
export function handleAnchors(block: BlockDef) {
|
||||
return {
|
||||
out: { x: pos.x + BLOCK_WIDTH / 2, y: pos.y + blockHeight(block) },
|
||||
in: { x: pos.x + BLOCK_WIDTH / 2, y: pos.y },
|
||||
out: { x: block.x + BLOCK_WIDTH / 2, y: block.y + blockHeight(block) },
|
||||
in: { x: block.x + BLOCK_WIDTH / 2, y: block.y },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { HeroVisual } from './hero-visual/hero-visual'
|
||||
@@ -40,9 +40,9 @@ import { TrustedBy } from '@/app/(landing)/components/trusted-by'
|
||||
* SIDEBAR
|
||||
* remains visible from the shot: the {@link HeroPlatformLoop} island overlays
|
||||
* the container interior (full-width chat that stages the workflow pane in,
|
||||
* replaying the conversation with the goo ThinkingLoader; blocks stay
|
||||
* draggable), inset a hair INSIDE the shot's own baked outlines so the visible
|
||||
* chrome is the real UI's pixels - never re-drawn.
|
||||
* replaying the conversation with the goo ThinkingLoader), inset a hair INSIDE
|
||||
* the shot's own baked outlines so the visible chrome is the real UI's pixels
|
||||
* - never re-drawn.
|
||||
* The frame is `1300/720` and the window `1080/620` at `83.08%` width, centered
|
||||
* - matching cursor.com's hero media proportions, with backdrop showing on all
|
||||
* four sides. Decorative, `aria-hidden`; the `--surface-3` fill remains as the
|
||||
|
||||
+4
-3
@@ -117,13 +117,13 @@ function PreviewFlow({ workflow, animate = false }: LandingPreviewWorkflowProps)
|
||||
edgeTypes={EDGE_TYPES}
|
||||
defaultEdgeOptions={{ type: 'previewEdge' }}
|
||||
elementsSelectable={false}
|
||||
nodesDraggable
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
panOnDrag
|
||||
panOnDrag={false}
|
||||
preventScrolling={false}
|
||||
autoPanOnNodeDrag={false}
|
||||
proOptions={PRO_OPTIONS}
|
||||
@@ -136,7 +136,8 @@ function PreviewFlow({ workflow, animate = false }: LandingPreviewWorkflowProps)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight ReactFlow canvas displaying an interactive workflow preview.
|
||||
* Lightweight ReactFlow canvas displaying a static workflow preview -
|
||||
* decorative, so panning, dragging, zooming, and selecting are all disabled.
|
||||
* The key on workflow.id forces a clean remount on switch - instant fitView,
|
||||
* no timers, no flicker.
|
||||
*/
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { useLazyMount } from '@/app/(landing)/hooks/use-lazy-mount'
|
||||
|
||||
/** Dimension-stable placeholder sized to the preview's exact footprint (zero CLS). */
|
||||
const PLACEHOLDER_CLASS = 'aspect-[1116/615] w-full rounded bg-[var(--surface-1)]'
|
||||
|
||||
/**
|
||||
* Load the preview chunk a little before it scrolls into view so it's ready by
|
||||
* the time the user reaches it, without paying for it on initial load.
|
||||
*/
|
||||
const PRELOAD_ROOT_MARGIN = '400px'
|
||||
|
||||
/**
|
||||
* Client mount for the {@link LandingPreview} - the heavy, animated workspace
|
||||
* island (framer-motion + reactflow). Isolated here so the sections that show it
|
||||
* stay Server Components: only this leaf is `'use client'`.
|
||||
*
|
||||
* Loaded with `ssr: false` so the framer-motion/reactflow bundle never ships in
|
||||
* the server-rendered HTML, and **gated on viewport proximity**: the chunk only
|
||||
* downloads once an {@link IntersectionObserver} reports the mount is near the
|
||||
* viewport, so the below-the-fold previews don't pull the heavy bundle into the
|
||||
* initial homepage load. A dimension-stable placeholder (the preview's exact
|
||||
* `aspect-[1116/615]` footprint, filled with the canvas surface) holds the space
|
||||
* before and during load, so there is zero layout shift or flash.
|
||||
* the server-rendered HTML, and gated on viewport proximity via
|
||||
* {@link useLazyMount} so the below-the-fold previews don't pull the heavy
|
||||
* bundle into the initial homepage load. A dimension-stable placeholder (the
|
||||
* preview's exact `aspect-[1116/615]` footprint, filled with the canvas
|
||||
* surface) holds the space before and during load, so there is zero layout
|
||||
* shift or flash.
|
||||
*/
|
||||
const LandingPreview = dynamic(
|
||||
() =>
|
||||
@@ -47,28 +41,7 @@ interface LandingPreviewMountProps {
|
||||
}
|
||||
|
||||
export function LandingPreviewMount({ autoplay, view, workflowId }: LandingPreviewMountProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) return
|
||||
// Graceful degradation: without IntersectionObserver support, load eagerly
|
||||
// rather than leave the preview stuck on its placeholder.
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setInView(true)
|
||||
return
|
||||
}
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) setInView(true)
|
||||
},
|
||||
{ rootMargin: PRELOAD_ROOT_MARGIN }
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [inView])
|
||||
const { ref, inView } = useLazyMount('400px')
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function MobileNav({ stars }: MobileNavProps) {
|
||||
|
||||
return (
|
||||
<div className='ml-auto flex items-center gap-2 lg:hidden'>
|
||||
<ChipLink variant='primary' href='/signup'>
|
||||
<ChipLink variant='primary' href='/signup' prefetch={false}>
|
||||
Sign up
|
||||
</ChipLink>
|
||||
<button
|
||||
@@ -154,6 +154,7 @@ export function MobileNav({ stars }: MobileNavProps) {
|
||||
href='/login'
|
||||
fullWidth
|
||||
flush
|
||||
prefetch={false}
|
||||
className='h-[40px] justify-center border border-[var(--border-1)] [&>span]:flex-none'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
|
||||
@@ -81,11 +81,13 @@ export function Navbar({ stars, logoOnly = false }: NavbarProps) {
|
||||
</div>
|
||||
|
||||
<div className='ml-auto hidden items-center gap-1 lg:flex'>
|
||||
<ChipLink href='/login'>Log in</ChipLink>
|
||||
<ChipLink href='/login' prefetch={false}>
|
||||
Log in
|
||||
</ChipLink>
|
||||
<ChipLink href='/demo' className='border border-[var(--border-1)]'>
|
||||
Contact sales
|
||||
</ChipLink>
|
||||
<ChipLink variant='primary' href='/signup'>
|
||||
<ChipLink variant='primary' href='/signup' prefetch={false}>
|
||||
Sign up
|
||||
</ChipLink>
|
||||
</div>
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ProductDemoVisualMount } from './product-demo-visual-mount'
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useLazyMount } from '@/app/(landing)/hooks/use-lazy-mount'
|
||||
|
||||
/**
|
||||
* `ssr: false` so the animation's client bundle never ships in the
|
||||
* server-rendered HTML for a section that starts below the fold.
|
||||
*/
|
||||
const HeroVisual = dynamic(
|
||||
() =>
|
||||
import('@/app/(landing)/components/hero/components/hero-visual/hero-visual').then(
|
||||
(mod) => mod.HeroVisual
|
||||
),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
/**
|
||||
* Client mount for the {@link HeroVisual} island reused in the Product Demo
|
||||
* section. Isolated here so `ProductDemo` stays a Server Component: only this
|
||||
* leaf is `'use client'`.
|
||||
*
|
||||
* Gated on viewport proximity via {@link useLazyMount} so the below-the-fold
|
||||
* section doesn't pull the animation's JS - or start its
|
||||
* `requestAnimationFrame` loop - into the initial homepage load. The parent
|
||||
* frame (`product-demo.tsx`) already reserves fixed pixel dimensions for this
|
||||
* slot, so there is no placeholder to size - an empty div holds the spot with
|
||||
* zero layout shift.
|
||||
*/
|
||||
export function ProductDemoVisualMount() {
|
||||
const { ref, inView } = useLazyMount('400px')
|
||||
|
||||
return (
|
||||
<div ref={ref} className='absolute inset-0'>
|
||||
{inView && <HeroVisual />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HeroVisual } from '@/app/(landing)/components/hero/components/hero-visual/hero-visual'
|
||||
import { LoaderMarks } from '@/app/(landing)/components/product-demo/components/loader-marks'
|
||||
import { ProductDemoVisualMount } from '@/app/(landing)/components/product-demo/components/product-demo-visual-mount'
|
||||
|
||||
/**
|
||||
* Product-demo band - the looping animated platform walkthrough, promoted out of
|
||||
@@ -12,8 +12,10 @@ import { LoaderMarks } from '@/app/(landing)/components/product-demo/components/
|
||||
* copy column on the right. Below `lg` it stacks media-over-copy like the
|
||||
* cards do.
|
||||
*
|
||||
* The visual is the same {@link HeroVisual} client island the hero used to
|
||||
* host (decorative, `aria-hidden`). The section carries the shared
|
||||
* The visual is the same `HeroVisual` client island the hero used to host
|
||||
* (decorative, `aria-hidden`), lazy-mounted via {@link ProductDemoVisualMount}
|
||||
* so its bundle and animation loop only load once this below-the-fold section
|
||||
* nears the viewport. The section carries the shared
|
||||
* `px-20 max-lg:px-8 max-sm:px-5` gutter and the `max-w-[1460px]` cap so the
|
||||
* card aligns with the hero's media frame above and the feature cards below.
|
||||
*/
|
||||
@@ -29,9 +31,7 @@ export function ProductDemo() {
|
||||
aria-hidden='true'
|
||||
className='relative h-[650px] overflow-hidden rounded-[4px] bg-[var(--surface-5)] max-sm:h-[280px] max-lg:h-[360px]'
|
||||
>
|
||||
<div className='absolute inset-0'>
|
||||
<HeroVisual />
|
||||
</div>
|
||||
<ProductDemoVisualMount />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col justify-center pr-4 max-lg:pr-0 max-lg:pb-2'>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Gates mounting a heavy client island on viewport proximity via
|
||||
* {@link IntersectionObserver}, so its bundle (and any `requestAnimationFrame`
|
||||
* loop) only loads once the section nears the viewport instead of on initial
|
||||
* page load. Falls back to an eager mount when `IntersectionObserver` is
|
||||
* unavailable, so the section never gets stuck unmounted.
|
||||
*
|
||||
* Pair with `next/dynamic(..., { ssr: false })` for the mounted component.
|
||||
*/
|
||||
export function useLazyMount(rootMargin: string) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) return
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setInView(true)
|
||||
return
|
||||
}
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) setInView(true)
|
||||
},
|
||||
{ rootMargin }
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [inView, rootMargin])
|
||||
|
||||
return { ref, inView }
|
||||
}
|
||||
@@ -914,7 +914,6 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
width={56}
|
||||
height={56}
|
||||
className='shrink-0 rounded-xl'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='h-px w-5 bg-[var(--border-1)]' aria-hidden='true' />
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { PUBLIC_ENV_KEY } from 'next-runtime-env'
|
||||
|
||||
/**
|
||||
* `NEXT_PUBLIC_*` values, captured once at module load (build time / server
|
||||
* start) rather than read per-request - correct on the hosted deployment,
|
||||
* where a build's env never changes between requests. Filter matches
|
||||
* `next-runtime-env`'s own `getPublicEnv()` exactly.
|
||||
*/
|
||||
const HOSTED_PUBLIC_ENV = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => /^NEXT_PUBLIC_/i.test(key))
|
||||
)
|
||||
|
||||
/**
|
||||
* Static, build-time equivalent of `next-runtime-env`'s `<PublicEnvScript>`
|
||||
* for the hosted deployment. It populates `window[PUBLIC_ENV_KEY]` with the
|
||||
* exact same shape `getEnv()` (`lib/core/config/env.ts`) reads client-side,
|
||||
* but without `next-runtime-env`'s unconditional `unstable_noStore()` call -
|
||||
* that call opts the entire app into dynamic rendering, which only pays off
|
||||
* for self-hosted Docker images that re-inject env per deploy without a
|
||||
* rebuild. On hosted, env is fixed per build, so this is safe to render
|
||||
* statically alongside the marketing pages' `revalidate`.
|
||||
*
|
||||
* Escapes `<` in the serialized JSON so an env value containing `</script>`
|
||||
* can't close this tag early and inject markup into every hosted page.
|
||||
*/
|
||||
export function PublicEnvScript() {
|
||||
const serialized = JSON.stringify(HOSTED_PUBLIC_ENV).replace(/</g, '\\u003c')
|
||||
return (
|
||||
<script
|
||||
id='public-env'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window['${PUBLIC_ENV_KEY}'] = ${serialized}`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import Script from 'next/script'
|
||||
import { PublicEnvScript } from 'next-runtime-env'
|
||||
import { PublicEnvScript as RuntimePublicEnvScript } from 'next-runtime-env'
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import { BrandedLayout } from '@/components/branded-layout'
|
||||
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
|
||||
@@ -12,6 +12,7 @@ import { QueryProvider } from '@/app/_shell/providers/query-provider'
|
||||
import { SessionProvider } from '@/app/_shell/providers/session-provider'
|
||||
import { ThemeProvider } from '@/app/_shell/providers/theme-provider'
|
||||
import { TooltipProvider } from '@/app/_shell/providers/tooltip-provider'
|
||||
import { PublicEnvScript } from '@/app/_shell/public-env-script'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
|
||||
export const viewport: Viewport = {
|
||||
@@ -227,7 +228,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
</>
|
||||
)}
|
||||
|
||||
<PublicEnvScript />
|
||||
{isHosted ? <PublicEnvScript /> : <RuntimePublicEnvScript />}
|
||||
</head>
|
||||
<body className={`${season.variable} font-season`} suppressHydrationWarning>
|
||||
{/* Google Tag Manager (noscript) — hosted only */}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ContentFrontmatterSchema = z
|
||||
authors: z.array(z.string()).min(1),
|
||||
readingTime: z.number().int().positive().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
ogImage: z.string().min(1),
|
||||
ogImage: z.string().min(1), // local path (e.g. /blog/<slug>/cover.jpg) - rendered via next/image without `unoptimized`
|
||||
ogAlt: z.string().optional(),
|
||||
about: z.array(z.string()).optional(),
|
||||
timeRequired: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user