mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
393 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|