Files
hanzo.ai/components/marketing/page-kit.tsx

393 lines
14 KiB
TypeScript

'use client'
/**
* page-kit — the shared shapes every simple marketing page is made of.
*
* Pages in `app/(marketing)` used to open with a byte-identical hero block, each
* with its own copy of the same badge/title/subtitle markup and motion config.
* This module is that block, once. A page supplies content; the kit owns layout,
* spacing and the same type the homepage uses.
*
* It is built on `@hanzo/gui` — the same substrate as @hanzo/ui's product layer,
* so these shapes render from the SAME tokens as the rest of the ecosystem
* (`lib/gui.ts` is @hanzo/ui's token table, the fleet's). Pages
* therefore MUST NOT reach around the kit with their own styling: pass content,
* not styling.
*/
import React from 'react'
import Link from 'next/link'
import { Pulse, YStack, XStack, Text, View } from '@hanzo/ui'
import { Grid } from '@hanzo/ui/grid'
import { Display, Document } from '@hanzo/ui/marketing'
import { under } from '@/components/shell/dock'
/**
* Any icon the kit can render.
*
* Typed by what the kit USES — it only ever sets `size` and `color` — not by the
* icon vendor. `LucideIcon` was the wrong shape for the slot: it is lucide's own
* `ForwardRefExoticComponent`, so a structurally identical icon from anywhere
* else (the `CloudIcon` in `lib/data/cloud-primitives.ts`, say) failed on
* `$$typeof` despite rendering perfectly. Every lucide icon still satisfies this.
*/
export type KitIcon = React.ComponentType<{ size?: number | string; color?: string }>
/**
* The page gutter + centred measure every block shares.
*
* `$sm`, not `$gtSm`: the v4 media set is mobile-first, so a bare breakpoint
* name IS the min-width query and the `gt`-prefixed v3 names no longer exist.
* They are not rejected inside a spread, so the wider gutter simply never
* applied — the page ran at the 16px mobile gutter on every viewport.
*/
const GUTTER = { px: '$4', $sm: { px: '$8' } } as const
const MEASURE = { width: '100%', maxW: 1120, mx: 'auto' } as const
/* ── hero ──────────────────────────────────────────────────────────────────── */
/**
* The atmosphere behind a page's opening: a soft glow of the accent from the
* top edge, and a faint ruled field under it that fades out before the copy
* ends. Two absolute layers under the hero's content; the hero clips them.
* The apex hero (components/home/Hero.tsx) wears the same two.
*/
export function Aura() {
return (
<>
<View
aria-hidden
position="absolute"
t={0}
l="50%"
x="-50%"
width="100%"
maxW={1024}
height={480}
backgroundImage="radial-gradient(ellipse 60% 50% at 50% 15%, color-mix(in srgb, var(--primary) 12%, transparent) 0%, color-mix(in srgb, var(--primary) 3%, transparent) 50%, transparent 80%)"
filter="blur(48px)"
pointerEvents="none"
z={0}
/>
<View
aria-hidden
position="absolute"
inset={0}
backgroundImage="linear-gradient(to right, color-mix(in srgb, var(--foreground) 3.5%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--foreground) 3.5%, transparent) 1px, transparent 1px)"
backgroundSize="64px 64px"
maskImage="linear-gradient(to bottom, transparent 0px, transparent calc(var(--header, 60px) + env(safe-area-inset-top, 0px)), black calc(var(--header, 60px) + env(safe-area-inset-top, 0px) + 60px), black 75%, transparent 100%)"
pointerEvents="none"
z={0}
/>
</>
)
}
/** The kicker over a page's title: a glass pill with a live dot in it. */
function Kicker({ icon: Icon, children }: { icon?: KitIcon; children: string }) {
return (
<XStack
render="p"
self="flex-start"
items="center"
gap={8}
m={0}
mb={16}
pt={4}
pb={4}
pl={10}
pr={12}
rounded="var(--radius-full)"
borderWidth={1}
borderColor="$edge/80"
bg="$hover/70"
backdropFilter="blur(8px)"
boxShadow="var(--shadow-sm)"
>
<Pulse render="span" aria-hidden width={6} height={6} rounded="var(--radius-full)" bg="var(--primary)" boxShadow="0 0 8px var(--primary)" />
{Icon ? <Icon size={14} color="var(--text-secondary)" /> : null}
<Text fontSize="$1" lineHeight="$1" fontWeight="500" letterSpacing={1.32} textTransform="uppercase" color="var(--text-secondary)">
{children}
</Text>
</XStack>
)
}
/** The opening's copy: the kicker, the title, the sentence under it, the actions. */
function Opening({ eyebrow, icon, title, lede, children }: { eyebrow: string; icon?: KitIcon; title: string; lede?: React.ReactNode; children?: React.ReactNode }) {
return (
<>
<Kicker icon={icon}>{eyebrow}</Kicker>
{/* Tracked tighter than the role, -0.03em at every rung, so a phone's
title holds one line. */}
<Display maxW="min(16em, 100%)" letterSpacing="-0.03em">
{title}
</Display>
{lede ? (
<Text render="p" display="block" maxW="40rem" mt={18} fontSize="$4" lineHeight="$6" color="var(--muted-foreground)">
{lede}
</Text>
) : null}
{children ? (
<XStack flexWrap="wrap" items="center" gap={12} mt={28}>
{children}
</XStack>
) : null}
</>
)
}
/**
* The opening of a simple marketing page.
*
* Same face as the homepage: the kicker, @hanzo/ui's `Display`, the sentence
* under it. The title is painted at full strength on first paint — a fade from
* transparent is what made these pages look unfinished. `icon` stays in the
* props so existing pages still typecheck; it sits in the kicker, not in a
* tinted pill. Behind the copy, the `Aura`.
*/
export function PageHero({
eyebrow,
icon: Icon,
title,
lede,
visual,
children,
}: {
eyebrow: string
icon?: KitIcon
title: string
/** A sentence, or a sentence and a `Spare` clause a phone leaves out. */
lede?: React.ReactNode
visual?: React.ReactNode
children?: React.ReactNode
}) {
return (
<YStack render="section" position="relative" overflow="hidden" {...under('$10')} pb="$6" $md={{ pt: under('$14').pt, pb: '$8' }} {...GUTTER}>
<Aura />
<YStack {...MEASURE} position="relative" z={1}>
{visual ? (
<XStack
flexDirection="column"
$md={{ flexDirection: 'row', items: 'center', justify: 'space-between' }}
gap="$8"
>
{/* `flex` from $md only: in the phone's column a zero basis with
gui's zero min-height collapses the copy to 0px under the visual. */}
<YStack maxW={640} $md={{ flex: 1 }}>
<Opening eyebrow={eyebrow} icon={Icon} title={title} lede={lede}>
{children}
</Opening>
</YStack>
<YStack maxW={520} width="100%" $md={{ flex: 1 }}>
{visual}
</YStack>
</XStack>
) : (
<Opening eyebrow={eyebrow} icon={Icon} title={title} lede={lede}>
{children}
</Opening>
)}
</YStack>
</YStack>
)
}
/* ── section ───────────────────────────────────────────────────────────────── */
export function Section({
title,
lede,
children,
}: {
title?: string
lede?: string
children: React.ReactNode
}) {
return (
<YStack render="section" py="$7" {...GUTTER}>
<YStack {...MEASURE}>
{title ? (
<Text
render="h2"
mb="$2"
fontSize="$7"
fontWeight="500"
letterSpacing="$7"
color="$ink"
>
{title}
</Text>
) : null}
{lede ? (
<Text render="p" mb="$5" maxW={672} fontSize="$3" color="$soft">
{lede}
</Text>
) : null}
{children}
</YStack>
</YStack>
)
}
/* ── cards ─────────────────────────────────────────────────────────────────── */
export interface CardItem {
title: string
description: string
href?: string
icon?: KitIcon
/** Shown right-aligned in the title row — e.g. a date or an endpoint verb. */
meta?: string
}
function CardBody({ item }: { item: CardItem }) {
const { title, description, icon: Icon, meta } = item
return (
<>
<XStack mb="$2" items="center" gap="$2">
{Icon ? <Icon size={16} color="var(--muted-foreground)" /> : null}
<Text fontSize="$3" fontWeight="500" color="$ink">
{title}
</Text>
{meta ? (
<Text ml="auto" shrink={0} fontSize="$1" color="$soft">
{meta}
</Text>
) : null}
</XStack>
<Text fontSize="$3" lineHeight="$4" color="$soft">
{description}
</Text>
</>
)
}
/**
* One-, two- or three-up card grid. A real grid, so tiles in a row share a
* height rather than each sizing to its own copy.
*
* NO BREAKPOINTS, and that is the whole reason it is `Grid` now. It used to
* name three of them — one column, then two at `$sm`, then `columns` at `$lg` —
* which is a guess about the VIEWPORT standing in for a fact about the CARD.
* `{ min: 280, max: n }` states the fact directly: a card is never narrower than
* 280px and a row never wider than n, and auto-fill counts how many fit the
* measure it is actually given. That answers correctly inside a narrow column on
* a wide screen, which no viewport query can.
*
* 280 is the card's floor, not a re-spelling of the 640px breakpoint it
* replaces: below ~608px of measure the row drops to one column on its own,
* within a few dozen pixels of where the old `$sm` did, and it no longer has to
* be told twice.
*
* `gap` is the NUMBER 16, not `'$4'`. `Grid` resolves a token through
* `getTokenValue(v, 'space')` and takes it only if it comes back a number —
* gui's ramp stores `calc(16px * var(--density, 1))`, a string, so every `$n`
* silently falls back to the 12px default. It is the arithmetic behind the
* `max` floor as well as the visible gutter, so the wrong value is wrong twice.
*/
const GAP = 16
export function CardGrid({ items, columns = 2 }: { items: CardItem[]; columns?: 1 | 2 | 3 }) {
return (
<Grid columns={columns === 1 ? 1 : { min: 280, max: columns }} gap={GAP}>
{items.map((item) => {
const frame = {
p: '$5',
borderWidth: 1,
borderColor: '$edge',
rounded: '$4',
hoverStyle: { borderColor: '$rim' },
} as const
return item.href ? (
<YStack
key={item.title}
render={<Link href={item.href} {...(item.href.startsWith('http') ? { target: '_blank', rel: 'noreferrer noopener' } : null)} />}
{...frame}
>
<CardBody item={item} />
</YStack>
) : (
<YStack key={item.title} {...frame}>
<CardBody item={item} />
</YStack>
)
})}
</Grid>
)
}
/* ── call to action ────────────────────────────────────────────────────────── */
/**
* A standalone action. `minHeight={44}` is the minimum comfortable touch target,
* which padding alone does not reach at this font size. Inline links inside
* `Prose` are deliberately not this; they belong in the text flow. A View keeps
* its content's width, so `maxW` is what lets a label longer than a phone's
* column wrap inside the pill instead of running off the side.
*/
export function Cta({
href,
children,
icon: Icon,
}: {
href: string
children: React.ReactNode
icon?: KitIcon
}) {
const external = href.startsWith('http')
return (
<XStack
render={<Link href={href} {...(external ? { target: '_blank', rel: 'noreferrer noopener' } : null)} />}
self="flex-start"
maxW="100%"
minH={44}
items="center"
gap="$2"
px="$4.5"
py="$2"
borderWidth={1}
borderColor="$edge"
rounded="$10"
hoverStyle={{ borderColor: '$rim' }}
>
<Text fontSize="$3" fontWeight="500" color="$ink">
{children}
</Text>
{Icon ? <Icon size={14} color="var(--foreground)" /> : null}
</XStack>
)
}
/* ── prose ─────────────────────────────────────────────────────────────────── */
/**
* Long-form body copy (policies, explanations). Headings come from `Section`.
*
* @hanzo/ui's `Document` sets the type. `html` is a document AUTHORED as
* markdown (content/legal/*.md) and rendered at build time, which arrives as
* bare tags no prop can reach; the same element holds a tree written here.
*/
export function Prose({ children, html }: { children?: React.ReactNode; html?: string }) {
return <Document html={html}>{children}</Document>
}
/* ── page wrapper ──────────────────────────────────────────────────────────── */
/**
* The outer frame; the marketing layout already supplies nav + footer.
*
* No `backgroundColor`. The ground is html's — stated once in globals.css so
* <DotMatrix/> can sit under the document — and a wrapper repainting it is an
* opaque sheet over the backdrop for every page built out of this kit.
*/
export function Page({ children }: { children: React.ReactNode }) {
return (
<YStack minH="100vh">
<YStack render="main" pb="$10">
{children}
</YStack>
</YStack>
)
}