diff --git a/src/templates/product/index.ts b/src/templates/product/index.ts index e33be29..ad42517 100644 --- a/src/templates/product/index.ts +++ b/src/templates/product/index.ts @@ -9,3 +9,4 @@ export { default as productAnnouncement } from './announcement'; export { default as productMinimalBadge } from './minimal-badge'; export { default as productDarkPremium } from './dark-premium'; export { default as productShortcutTip } from './shortcut-tip'; +export { default as productWaitlist } from './waitlist'; diff --git a/src/templates/product/waitlist.ts b/src/templates/product/waitlist.ts new file mode 100644 index 0000000..1380e2f --- /dev/null +++ b/src/templates/product/waitlist.ts @@ -0,0 +1,239 @@ +import type { TemplateDefinition } from '../types'; +import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils'; + +const template: TemplateDefinition = { + id: 'product-waitlist', + name: 'Waitlist', + category: 'product', + description: 'Pre-launch waitlist card with the product name, a one-line pitch, the number of people waiting and an optional launch window', + tags: ['product', 'waitlist', 'launch', 'signups', 'pre-launch'], + fields: [ + commonFields.title, + commonFields.description, + { key: 'count', label: 'Waitlist Count', type: 'text', defaultValue: '2,148', placeholder: '2,148', group: 'Content' }, + { key: 'countLabel', label: 'Count Label', type: 'text', defaultValue: 'people on the list', placeholder: 'people on the list', group: 'Content' }, + { key: 'launchWindow', label: 'Launch Window', type: 'text', defaultValue: 'Opening in November', placeholder: 'Opening in November', group: 'Content' }, + commonFields.siteName, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + title: 'Notewell', + description: 'Notes that link themselves', + count: '2,148', + countLabel: 'people on the list', + launchWindow: 'Opening in November', + siteName: 'notewell.app', + bgColor: '#0a0a0a', + accentColor: '#E07A5F', + textColor: '#FFFFFF', + }, + render: (params) => { + const rawTitle = String(params.title || 'Notewell').trim() || 'Notewell'; + const titleSize = autoFontSize(rawTitle, [ + { maxLen: 16, size: 88 }, + { maxLen: 28, size: 72 }, + { maxLen: 42, size: 60 }, + ]); + const title = truncate(rawTitle, 42); + const rawDescription = String(params.description || '').trim(); + const descriptionSize = autoFontSize(rawDescription, [ + { maxLen: 34, size: 30 }, + { maxLen: 60, size: 26 }, + { maxLen: 90, size: 22 }, + ]); + const description = truncate(rawDescription, 90); + const count = truncate(String(params.count || '').trim(), 12); + // The label describes the number, so it goes with it rather than standing on its own. + const countLabel = count ? truncate(String(params.countLabel || '').trim(), 32) : ''; + const launchWindow = truncate(String(params.launchWindow || '').trim(), 40); + const siteName = truncate(String(params.siteName || '').trim(), 40); + const bgColor = String(params.bgColor || '#0a0a0a'); + const accentColor = String(params.accentColor || '#E07A5F'); + const textColor = String(params.textColor || '#FFFFFF'); + // The label and the launch window are drawn in the accent on the page background, + // so they are darkened when that background is light. + const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor; + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + width: '1200px', + height: '630px', + boxSizing: 'border-box', + backgroundColor: bgColor, + fontFamily: 'Inter, sans-serif', + padding: '64px 80px', + }, + children: [ + // "Join the waitlist" label + { + type: 'span', + props: { + style: { + fontSize: '16px', + fontWeight: 700, + color: accentInk, + letterSpacing: '0.16em', + textTransform: 'uppercase' as const, + }, + children: 'Join the waitlist', + }, + }, + // Product name + { + type: 'h1', + props: { + style: { + fontSize: `${titleSize}px`, + fontWeight: 700, + color: textColor, + lineHeight: 1.1, + margin: 0, + marginTop: '18px', + }, + children: title, + }, + }, + // One-line pitch + ...(description + ? [ + { + type: 'p', + props: { + style: { + fontSize: `${descriptionSize}px`, + fontWeight: 500, + color: `${textColor}B3`, + lineHeight: 1.4, + margin: 0, + marginTop: '14px', + }, + children: description, + }, + }, + ] + : []), + // Count and its label + ...(count + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'baseline', + gap: '14px', + marginTop: '52px', + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '64px', + fontWeight: 700, + color: textColor, + lineHeight: 1, + fontFamily: 'JetBrains Mono, monospace', + }, + children: count, + }, + }, + ...(countLabel + ? [ + { + type: 'span', + props: { + style: { + fontSize: '22px', + fontWeight: 500, + color: `${textColor}99`, + }, + children: countLabel, + }, + }, + ] + : []), + ], + }, + }, + ] + : []), + // Launch window and site name + ...(launchWindow || siteName + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '16px', + marginTop: '44px', + }, + children: [ + ...(launchWindow + ? [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 600, + color: accentInk, + }, + children: launchWindow, + }, + }, + ] + : []), + ...(launchWindow && siteName + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + width: '4px', + height: '4px', + borderRadius: '50%', + backgroundColor: `${textColor}44`, + }, + children: [], + }, + }, + ] + : []), + ...(siteName + ? [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 500, + color: `${textColor}66`, + }, + children: siteName, + }, + }, + ] + : []), + ], + }, + }, + ] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 48b6f0b..c2cfe91 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -86,12 +86,13 @@ import productAnnouncement from './product/announcement'; import productMinimalBadge from './product/minimal-badge'; import productDarkPremium from './product/dark-premium'; import productShortcutTip from './product/shortcut-tip'; +import productWaitlist from './product/waitlist'; [ productCenteredHero, productSplitScreen, productGradientWave, productNeonGlow, productGlassmorphism, productAppShowcase, productFeatureGrid, productAnnouncement, productMinimalBadge, productDarkPremium, - productShortcutTip, + productShortcutTip, productWaitlist, ].forEach(registerTemplate); // SaaS diff --git a/tests/templates/waitlist.test.ts b/tests/templates/waitlist.test.ts new file mode 100644 index 0000000..090d25c --- /dev/null +++ b/tests/templates/waitlist.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import waitlist from '@/templates/product/waitlist'; +import { getTemplate } from '@/templates/registry'; +import type { SatoriElement } from '@/templates/types'; + +function elements(node: unknown): SatoriElement[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!node || typeof node !== 'object' || !('type' in node)) return []; + + const element = node as SatoriElement; + return [element, ...elements(element.props.children)]; +} + +function textContent(node: unknown): string[] { + if (typeof node === 'string') return [node]; + if (Array.isArray(node)) return node.flatMap(textContent); + if (node && typeof node === 'object' && 'props' in node) { + return textContent((node as SatoriElement).props.children); + } + return []; +} + +const render = (overrides: Record = {}) => + waitlist.render({ ...waitlist.defaults, ...overrides }); + +describe('product-waitlist', () => { + it('is registered in the product category and re-exported', async () => { + expect(getTemplate('product-waitlist')?.category).toBe('product'); + const exports = await import('@/templates/product'); + expect(exports.productWaitlist.id).toBe('product-waitlist'); + }); + + it('renders the label, product name, pitch, count with its label and the launch window', () => { + expect(textContent(render())).toEqual([ + 'Join the waitlist', + 'Notewell', + 'Notes that link themselves', + '2,148', + 'people on the list', + 'Opening in November', + 'notewell.app', + ]); + }); + + it('hides the count label whenever the count is empty', () => { + expect(textContent(render({ count: ' ' }))).toEqual([ + 'Join the waitlist', + 'Notewell', + 'Notes that link themselves', + 'Opening in November', + 'notewell.app', + ]); + }); + + it('drops a blank description, launch window and site name cleanly', () => { + expect(textContent(render({ description: ' ', launchWindow: '', siteName: ' ' }))).toEqual([ + 'Join the waitlist', + 'Notewell', + '2,148', + 'people on the list', + ]); + }); + + it('keeps the separator out when only one of the launch window and site name is set', () => { + const onlySite = elements(render({ launchWindow: ' ' })); + const dots = onlySite.filter((element) => element.type === 'div' && + element.props.style?.width === '4px' && element.props.style?.height === '4px'); + + expect(dots).toHaveLength(0); + expect(textContent(render({ launchWindow: ' ' }))).toEqual([ + 'Join the waitlist', + 'Notewell', + 'Notes that link themselves', + '2,148', + 'people on the list', + 'notewell.app', + ]); + }); + + it('shrinks long product names and pitches with autoFontSize and then truncates them', () => { + const longTitle = 'Notewell Collaborative Knowledge Workspace for Distributed Engineering Teams'; + const longPitch = 'Notes that link themselves, find their own context, and stay in sync across every device and every teammate you work with'; + const nodes = elements(render({ title: longTitle, description: longPitch })); + + const heading = nodes.find((element) => element.type === 'h1'); + expect(textContent(heading)[0]).toHaveLength(42); + expect(textContent(heading)[0]).toMatch(/…$/); + expect(heading?.props.style?.fontSize).toBe('60px'); + + const pitch = nodes.find((element) => element.type === 'p'); + expect(textContent(pitch)[0].length).toBeLessThanOrEqual(90); + expect(textContent(pitch)[0]).toMatch(/…$/); + expect(pitch?.props.style?.fontSize).toBe('22px'); + }); + + it('keeps the accent text readable on a light background', () => { + const dark = elements(render()); + const light = elements(render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' })); + + const darkLabel = dark.find((element) => element.type === 'span' && + textContent(element)[0] === 'Join the waitlist'); + expect(darkLabel?.props.style?.color).toBe('#E07A5F'); + + const lightLabel = light.find((element) => element.type === 'span' && + textContent(element)[0] === 'Join the waitlist'); + expect(lightLabel?.props.style?.color).toBe('#8b7b27'); + + const lightWindow = light.find((element) => element.type === 'span' && + textContent(element)[0] === 'Opening in November'); + expect(lightWindow?.props.style?.color).toBe('#8b7b27'); + }); +});