diff --git a/apps/sim/app/(landing)/components/cta/cta.test.tsx b/apps/sim/app/(landing)/components/cta/cta.test.tsx new file mode 100644 index 00000000000..f4d5eed5a84 --- /dev/null +++ b/apps/sim/app/(landing)/components/cta/cta.test.tsx @@ -0,0 +1,50 @@ +/** @vitest-environment jsdom */ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { renderToStaticMarkup } from 'react-dom/server' +import sharp from 'sharp' +import { describe, expect, it, vi } from 'vitest' +import { Cta } from '@/app/(landing)/components/cta/cta' +import { FOOTER_ARTWORK } from '@/app/(landing)/components/cta/footer-artwork.generated' + +vi.mock('@/app/(landing)/components/hero-cta', () => ({ HeroCta: () => null })) + +describe('Footer artwork delivery', () => { + it('renders responsive static sources with lazy, decorative fallbacks and no optimizer requests', () => { + const host = document.createElement('div') + host.innerHTML = renderToStaticMarkup() + expect(host.innerHTML).not.toContain('/_next/image') + expect(host.querySelectorAll('picture')).toHaveLength(2) + expect(host.querySelector('picture')?.className).toContain('dark:hidden') + expect(host.querySelectorAll('picture')[1].className).toContain('dark:block') + for (const picture of host.querySelectorAll('picture')) { + expect(picture.closest('[aria-hidden="true"]')).not.toBeNull() + expect([...picture.querySelectorAll('source')].map((source) => source.type)).toEqual([ + 'image/avif', + 'image/webp', + ]) + const image = picture.querySelector('img')! + expect(image.getAttribute('loading')).toBe('lazy') + expect(image.alt).toBe('') + expect(image.src).toContain('/landing/footer-artwork/') + expect(image.getAttribute('style')).toContain('data:image/svg+xml') + } + }) + + it('ships every advertised size with the matching dimensions and immutable content hash', async () => { + for (const artwork of Object.values(FOOTER_ARTWORK)) { + for (const srcSet of [artwork.avifSrcSet, artwork.webpSrcSet]) { + for (const entry of srcSet.split(', ')) { + const [src, descriptor] = entry.split(' ') + const width = Number.parseInt(descriptor, 10) + const buffer = await readFile(path.join(process.cwd(), 'public', src)) + const metadata = await sharp(buffer).metadata() + expect(metadata.width).toBe(width) + expect(metadata.height).toBe((width * 9) / 16) + expect(src).toContain(createHash('sha256').update(buffer).digest('hex').slice(0, 12)) + } + } + } + }) +}) diff --git a/apps/sim/app/(landing)/components/cta/cta.tsx b/apps/sim/app/(landing)/components/cta/cta.tsx index af8d30cc038..665c91faee9 100644 --- a/apps/sim/app/(landing)/components/cta/cta.tsx +++ b/apps/sim/app/(landing)/components/cta/cta.tsx @@ -1,21 +1,22 @@ import { cn } from '@sim/emcn' import Image from 'next/image' import styles from '@/app/(landing)/components/cta/cta.module.css' +import { FOOTER_ARTWORK } from '@/app/(landing)/components/cta/footer-artwork.generated' import { HeroCta } from '@/app/(landing)/components/hero-cta' import { HOME_TYPE, LANDING_CONTENT_WIDTH, LANDING_GUTTER, } from '@/app/(landing)/components/landing-layout' -import ctaDark from '@/public/landing/cta-san-francisco-painted-dark.webp' -import ctaLight from '@/public/landing/cta-san-francisco-painted-light.webp' /** 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 +const ARTWORK_SIZES = '(max-width: 639px) 112vw, 100vw' /** * Painted pre-footer CTA for every marketing page, mounted once by LandingShell. * Theme classes select the matching lazy-loaded painting without client state. + * Pre-encoded sources avoid request-time image optimization at every resolution. * An embedded preview fills the scene while the full-resolution image loads. * The sky mask keeps the copy clear and the lower edge fades into the footer. */ @@ -52,26 +53,29 @@ export function Cta() { aria-hidden='true' className='-mt-[clamp(96px,12.5vw,240px)] max-sm:-mt-6 pointer-events-none relative aspect-video w-full max-sm:aspect-[16/10]' > - - + {Object.entries(FOOTER_ARTWORK).map(([theme, artwork]) => ( + + + + + + ))} ) diff --git a/apps/sim/app/(landing)/components/cta/footer-artwork.generated.ts b/apps/sim/app/(landing)/components/cta/footer-artwork.generated.ts new file mode 100644 index 00000000000..fcaa1d44fc5 --- /dev/null +++ b/apps/sim/app/(landing)/components/cta/footer-artwork.generated.ts @@ -0,0 +1,21 @@ +/** Generated by scripts/generate-footer-artwork.ts. */ +export const FOOTER_ARTWORK = { + light: { + avifSrcSet: + '/landing/footer-artwork/light-1200-f29e84fa39b5.avif 1200w, /landing/footer-artwork/light-1920-352cf7c570ad.avif 1920w, /landing/footer-artwork/light-3840-e889bb4bc039.avif 3840w', + webpSrcSet: + '/landing/footer-artwork/light-1200-8764ab359e07.webp 1200w, /landing/footer-artwork/light-1920-c9ec24660024.webp 1920w, /landing/footer-artwork/light-3840-6a6c3eab4b02.webp 3840w', + src: '/landing/footer-artwork/light-3840-6a6c3eab4b02.webp', + blurDataURL: + 'data:image/webp;base64,UklGRjYAAABXRUJQVlA4ICoAAACwAQCdASoIAAQAAkA4JaQAAxaZftQAAP75igYWrDK4ciyu9bneK5BAAAA=', + }, + dark: { + avifSrcSet: + '/landing/footer-artwork/dark-1200-a1f7345fe7f6.avif 1200w, /landing/footer-artwork/dark-1920-638fa2f584cb.avif 1920w, /landing/footer-artwork/dark-3840-c0bf8a67335d.avif 3840w', + webpSrcSet: + '/landing/footer-artwork/dark-1200-117c8741a843.webp 1200w, /landing/footer-artwork/dark-1920-2c586b41810a.webp 1920w, /landing/footer-artwork/dark-3840-3140a296c362.webp 3840w', + src: '/landing/footer-artwork/dark-3840-3140a296c362.webp', + blurDataURL: + 'data:image/webp;base64,UklGRiwAAABXRUJQVlA4ICAAAABwAQCdASoIAAQAAkA4JaRtgAAIQAD+9MlFFCUE/BGgAA==', + }, +} as const diff --git a/apps/sim/app/(landing)/components/landing-shell/landing-shell.test.tsx b/apps/sim/app/(landing)/components/landing-shell/landing-shell.test.tsx index b707d1afc11..67b6a338242 100644 --- a/apps/sim/app/(landing)/components/landing-shell/landing-shell.test.tsx +++ b/apps/sim/app/(landing)/components/landing-shell/landing-shell.test.tsx @@ -9,9 +9,8 @@ import ts from '@typescript/typescript6' import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FOOTER_ARTWORK } from '@/app/(landing)/components/cta/footer-artwork.generated' import { LandingShell } from '@/app/(landing)/components/landing-shell/landing-shell' -import ctaDark from '@/public/landing/cta-san-francisco-painted-dark.webp' -import ctaLight from '@/public/landing/cta-san-francisco-painted-light.webp' interface TestLinkProps extends AnchorHTMLAttributes { href: string @@ -113,8 +112,8 @@ describe('LandingShell shared closing section', () => { 'Every agent your company runs. All in one place. ' ) expect(Array.from(cta.querySelectorAll('img'), (image) => image.getAttribute('src'))).toEqual([ - ctaLight, - ctaDark, + FOOTER_ARTWORK.light.src, + FOOTER_ARTWORK.dark.src, ]) expect(Array.from(cta.querySelectorAll('img'), (image) => image.alt)).toEqual(['', '']) expect( diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 7f315651b63..cc5c48bba11 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -268,6 +268,11 @@ const nextConfig: NextConfig = { }, ], }, + { + /** Generated footer artwork uses content hashes, so URLs are immutable. */ + source: '/landing/footer-artwork/:path*', + headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }], + }, { source: '/.well-known/:path*', headers: [ diff --git a/apps/sim/public/landing/footer-artwork/dark-1200-117c8741a843.webp b/apps/sim/public/landing/footer-artwork/dark-1200-117c8741a843.webp new file mode 100644 index 00000000000..38d54b39b14 Binary files /dev/null and b/apps/sim/public/landing/footer-artwork/dark-1200-117c8741a843.webp differ diff --git a/apps/sim/public/landing/footer-artwork/dark-1200-a1f7345fe7f6.avif b/apps/sim/public/landing/footer-artwork/dark-1200-a1f7345fe7f6.avif new file mode 100644 index 00000000000..9c800ff6955 Binary files /dev/null and b/apps/sim/public/landing/footer-artwork/dark-1200-a1f7345fe7f6.avif differ diff --git a/apps/sim/public/landing/footer-artwork/dark-1920-2c586b41810a.webp b/apps/sim/public/landing/footer-artwork/dark-1920-2c586b41810a.webp new file mode 100644 index 00000000000..0ff68f03bfa Binary files /dev/null and b/apps/sim/public/landing/footer-artwork/dark-1920-2c586b41810a.webp differ diff --git a/apps/sim/public/landing/footer-artwork/dark-1920-638fa2f584cb.avif b/apps/sim/public/landing/footer-artwork/dark-1920-638fa2f584cb.avif new file mode 100644 index 00000000000..f878315bc9c Binary files /dev/null and b/apps/sim/public/landing/footer-artwork/dark-1920-638fa2f584cb.avif differ diff --git a/apps/sim/public/landing/footer-artwork/dark-3840-3140a296c362.webp b/apps/sim/public/landing/footer-artwork/dark-3840-3140a296c362.webp new file mode 100644 index 00000000000..c15f94a86e5 Binary files /dev/null and b/apps/sim/public/landing/footer-artwork/dark-3840-3140a296c362.webp differ diff --git a/apps/sim/public/landing/footer-artwork/dark-3840-c0bf8a67335d.avif b/apps/sim/public/landing/footer-artwork/dark-3840-c0bf8a67335d.avif new file mode 100644 index 00000000000..6b1cbd10561 Binary files /dev/null and b/apps/sim/public/landing/footer-artwork/dark-3840-c0bf8a67335d.avif differ diff --git a/apps/sim/public/landing/footer-artwork/light-1200-8764ab359e07.webp b/apps/sim/public/landing/footer-artwork/light-1200-8764ab359e07.webp new file mode 100644 index 00000000000..f3512895a4e Binary files /dev/null and b/apps/sim/public/landing/footer-artwork/light-1200-8764ab359e07.webp differ diff --git a/apps/sim/public/landing/footer-artwork/light-1200-f29e84fa39b5.avif b/apps/sim/public/landing/footer-artwork/light-1200-f29e84fa39b5.avif new file mode 100644 index 00000000000..75c79595d7c Binary files /dev/null and b/apps/sim/public/landing/footer-artwork/light-1200-f29e84fa39b5.avif differ diff --git a/apps/sim/public/landing/footer-artwork/light-1920-352cf7c570ad.avif b/apps/sim/public/landing/footer-artwork/light-1920-352cf7c570ad.avif new file mode 100644 index 00000000000..4e2f1cc21e0 Binary files /dev/null and b/apps/sim/public/landing/footer-artwork/light-1920-352cf7c570ad.avif differ diff --git a/apps/sim/public/landing/footer-artwork/light-1920-c9ec24660024.webp b/apps/sim/public/landing/footer-artwork/light-1920-c9ec24660024.webp new file mode 100644 index 00000000000..1698a9c7a44 Binary files /dev/null and b/apps/sim/public/landing/footer-artwork/light-1920-c9ec24660024.webp differ diff --git a/apps/sim/public/landing/footer-artwork/light-3840-6a6c3eab4b02.webp b/apps/sim/public/landing/footer-artwork/light-3840-6a6c3eab4b02.webp new file mode 100644 index 00000000000..b6bbc2fff91 Binary files /dev/null and b/apps/sim/public/landing/footer-artwork/light-3840-6a6c3eab4b02.webp differ diff --git a/apps/sim/public/landing/footer-artwork/light-3840-e889bb4bc039.avif b/apps/sim/public/landing/footer-artwork/light-3840-e889bb4bc039.avif new file mode 100644 index 00000000000..12570ca4ad0 Binary files /dev/null and b/apps/sim/public/landing/footer-artwork/light-3840-e889bb4bc039.avif differ diff --git a/scripts/generate-footer-artwork.ts b/scripts/generate-footer-artwork.ts new file mode 100644 index 00000000000..c2747d21aac --- /dev/null +++ b/scripts/generate-footer-artwork.ts @@ -0,0 +1,81 @@ +/** + * Pre-encodes the footer paintings so visitors never wait for image optimization. + * Run `bun run scripts/generate-footer-artwork.ts` after changing either master. + * Generated filenames include the encoded content hash for immutable caching. + */ +import { createHash } from 'node:crypto' +import { mkdir, readdir, unlink, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { createLogger } from '@sim/logger' +import sharp from 'sharp' + +const logger = createLogger('FooterArtwork') +const ROOT = path.resolve(import.meta.dirname, '..') +const PUBLIC_DIR = path.join(ROOT, 'apps/sim/public/landing') +const OUTPUT_DIR = path.join(PUBLIC_DIR, 'footer-artwork') +const MANIFEST = path.join( + ROOT, + 'apps/sim/app/(landing)/components/cta/footer-artwork.generated.ts' +) +const WIDTHS = [1200, 1920, 3840] as const +const THEMES = ['light', 'dark'] as const +/** Match the existing Next.js quality=90 WebP and AVIF encodes. */ +const QUALITY = 90 + +interface FooterArtwork { + avifSrcSet: string + webpSrcSet: string + src: string + blurDataURL: string +} + +sharp.concurrency(2) +await mkdir(OUTPUT_DIR, { recursive: true }) +const generatedFiles = new Set() +const artwork: Record = {} + +for (const theme of THEMES) { + const master = path.join(PUBLIC_DIR, `cta-san-francisco-painted-${theme}.webp`) + const sources: Record<'avif' | 'webp', string[]> = { avif: [], webp: [] } + let fallback = '' + for (const format of ['avif', 'webp'] as const) { + for (const width of WIDTHS) { + const image = sharp(master).rotate().resize(width, undefined, { withoutEnlargement: true }) + const buffer = await (format === 'avif' + ? image.avif({ quality: Math.round(QUALITY * (50 / 80)), effort: 3 }) + : image.webp({ quality: QUALITY }) + ).toBuffer() + const hash = createHash('sha256').update(buffer).digest('hex').slice(0, 12) + const filename = `${theme}-${width}-${hash}.${format}` + await writeFile(path.join(OUTPUT_DIR, filename), buffer) + generatedFiles.add(filename) + const url = `/landing/footer-artwork/${filename}` + sources[format].push(`${url} ${width}w`) + if (format === 'webp') fallback = url + logger.info('Encoded footer artwork', { theme, width, format, bytes: buffer.length }) + } + } + const preview = await sharp(master).resize(8).webp({ quality: 70 }).toBuffer() + artwork[theme] = { + avifSrcSet: sources.avif.join(', '), + webpSrcSet: sources.webp.join(', '), + src: fallback, + blurDataURL: `data:image/webp;base64,${preview.toString('base64')}`, + } +} + +await writeFile( + MANIFEST, + `/** Generated by scripts/generate-footer-artwork.ts. */\nexport const FOOTER_ARTWORK = ${JSON.stringify(artwork, null, 2)} as const\n` +) +const format = Bun.spawn(['bunx', 'biome', 'format', '--write', MANIFEST], { cwd: ROOT }) +if ((await format.exited) !== 0) throw new Error('Could not format the footer artwork manifest') + +for (const filename of await readdir(OUTPUT_DIR)) { + if ( + /^(light|dark)-\d+-[a-f0-9]{12}\.(avif|webp)$/.test(filename) && + !generatedFiles.has(filename) + ) { + await unlink(path.join(OUTPUT_DIR, filename)) + } +}