Files
zeekayandGitHub 3df66c3990 i18n: /m* through /r* and the product kits draw their words through t (#70)
Pages from /machines to /risk, the model pages, and the kits they are made of
(page-kit, ProductLanding, LayerPage, Stack, the models kit, band's Head) mark
authored copy where it is written and draw it in the reader's language. A server
page marks a literal phrase('…') or wraps it in <T>; a client page calls t. The
kits translate a string prop through useSay (components/marketing/say), so a
page passes phrase('…') and markup passes as written. An arrow that points
turns over right to left (data-i18n-mirror).

Rebased on main: the fold-based heroes (Fold, PageHero, MachinesHero, the
product and layer pages) keep main's structure and copy, with their label,
title and lede marked phrase. /platform is main's CloudLanding; the deleted
/platform/layers and the old referral components stay deleted.

Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
2026-10-30 00:18:44 -07:00

287 lines
10 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. A word the page passes as a
* string is drawn through `t`, so the page marks it `phrase('…')` (@hanzo/i18n)
* where it writes it, server page or client.
*
* 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 { YStack, XStack, Text } from '@hanzo/ui'
import { Grid } from '@hanzo/ui/grid'
import { Document } from '@hanzo/ui/marketing'
import { Fold } from '@/components/marketing/fold'
import { INSET } from '@/components/shell/dock'
import { useI18n } from '@hanzo/i18n/react'
import { useSay } from '@/components/marketing/say'
/**
* 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's gutter and measure (components/shell/dock), the bar's: a section's
* words start where the wordmark does, on every width.
*/
const GUTTER = { px: INSET } as const
const MEASURE = { width: '100%' } as const
/* ── hero ──────────────────────────────────────────────────────────────────── */
/**
* The opening of a simple marketing page: the first screen
* (components/marketing/fold) with the page's name in the pill, its title,
* the sentence under it and its actions. With a `visual` — the thing the page
* is about, running — the screen is the window under the bar; without one (a
* policy, a document) it keeps its own height.
*/
export function PageHero({
eyebrow,
icon: Icon,
title,
lede,
visual,
children,
}: {
eyebrow: string
icon?: KitIcon
/** A string, or the heading a server page draws itself when it carries a figure. */
title: React.ReactNode
/** A sentence, or a sentence and a `Spare` clause a phone leaves out. */
lede?: React.ReactNode
visual?: React.ReactNode
children?: React.ReactNode
}) {
return (
<Fold
label={eyebrow}
mark={Icon ? <Icon size={12} color="currentColor" /> : undefined}
title={title}
lede={lede}
actions={children}
preview={visual}
/>
)
}
/* ── section ───────────────────────────────────────────────────────────────── */
export function Section({
title,
lede,
children,
}: {
title?: string
/** A string, or the sentence a server page draws itself when it carries a figure. */
lede?: React.ReactNode
children: React.ReactNode
}) {
const say = useSay()
return (
<YStack render="section" py="$7" {...GUTTER}>
<YStack {...MEASURE}>
{title ? (
<Text
render="h2"
mb="$2"
fontSize="$7"
fontWeight="500"
letterSpacing="$7"
color="$ink"
>
{say(title)}
</Text>
) : null}
{lede ? (
<Text render="p" mb="$5" maxW={672} fontSize="$3" color="$soft">
{say(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 { t } = useI18n()
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">
{t(title)}
</Text>
{meta ? (
<Text marginInlineStart="auto" shrink={0} fontSize="$1" color="$soft">
{t(meta)}
</Text>
) : null}
</XStack>
<Text fontSize="$3" lineHeight="$4" color="$soft">
{t(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 say = useSay()
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">
{say(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>
)
}