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