Files
zeekay 0f5536b707 hanzo.ai reads in the reader's language, and Arabic right to left
Every word the landing, the header menus, the phone sheet, the footer,
the help corner and the way in (sign-in, sign-up, the reset, the
invitation, the callback, onboarding, the 404) write goes through
@hanzo/i18n's t(): one sentence is one message, its figures, names and
links arguments; copy written as data is phrase('…') where written and
t(label) where drawn. Plans, the chart, the API states and the lines
lib/kai and lib/enso build for the landing do the same.

Right to left: <html lang dir> is set before the first paint (the
package's bootScript first in the head); a figure, clock, range or
price outside a sentence is isolated; sides are logical gui props, the
layout's [data-join] corners logical, a bar grows from the start;
arrows along the line mirror (Onward for "words ->" links). The repo
dates read in the reader's language.

Gates select by slot, never by an English label (rtl, help). New gate
translated.spec.ts: in Arabic the landing at 1440 and 390, every menu
open, the phone sheet, sign-in, sign-up and the app signed in hold no
authored English, nothing scrolls sideways, every range is isolated;
French the same for English's commonest words. What another package
draws with no words prop is listed by name (@hanzo/ui's Footer label,
@hanzo/build's composer row and suggestions).

@hanzo/i18n 0.5.3 (the catalogs: 1,443 keys, every offered locale
complete), @hanzo/app 0.2.1 (the app's words). The link gate imports
the terms versions from @hanzo/app, the one SDK main now resolves.
2026-10-22 19:54:31 -07:00

429 lines
13 KiB
TypeScript

'use client'
/**
* The shapes /pricing is made of, stated once, in gui props.
*
* Six rate cards had each grown a private copy of the same three things — a tier
* card, a bordered rate table, and an "included with every plan" panel — and the
* tier card existed in TWO designs sitting one above the other on the same tab
* (Datastore at p-7 with a pill badge, Vector and Observability at p-6 with a
* caps one). Two designs for one concept is not a preference held in two files;
* it is the same card drawn twice and drifting.
*
* A caller passes CONTENT. Nothing here takes a class name, and nothing here
* reads one: the type ramp, the surface and the rhythm are gui props against the
* same tokens `lib/gui.ts` binds, so the page renders from the design system
* rather than from a stylesheet a build step happened to emit.
*/
import type { ViewProps } from '@hanzo/gui'
import type { ReactNode } from 'react'
import { View, YStack, XStack } from '@hanzo/ui'
import { Line, type Tone } from '@/components/type'
import { Grid } from '@hanzo/ui/grid'
import { Action as Pill } from '@hanzo/ui/marketing'
import { mix } from '@hanzo/app/lib/mix'
/* ── surface ───────────────────────────────────────────────────────────────── */
/** The one card surface this page paints — neutral fill, hairline, 16px radius. */
const SURFACE = {
bg: '$panel',
borderWidth: 1,
borderColor: '$edge',
rounded: 'var(--radius-xl)',
} as const
export function Panel({
pad = '$5',
children,
...rest
}: {
pad?: ViewProps['p']
children: ReactNode
[k: string]: unknown
}) {
return (
<YStack {...SURFACE} p={pad} {...rest}>
{children}
</YStack>
)
}
/* ── heads ─────────────────────────────────────────────────────────────────── */
/** A service's name and what it is, with an optional link out on the same line. */
export function Head({
title,
lede,
icon,
aside,
gap = '$6',
}: {
title: string
lede?: ReactNode
icon?: ReactNode
aside?: ReactNode
/** Space beneath the lede. */
gap?: ViewProps['gap']
}) {
const heading = (
<XStack items="center" gap="$3">
{icon}
<Line render="h2" size="x3" weight="700">
{title}
</Line>
</XStack>
)
const words = (
<>
<YStack mb={lede ? '$2' : undefined}>{heading}</YStack>
{lede ? (
<Line render="p" size="lg" tone="muted">
{lede}
</Line>
) : null}
</>
)
// The link goes BESIDE the words, not between the title and its lede. Putting
// it on the heading's own row reads correctly in a picture and wrongly in
// every sequential reading of the page — the pill lands mid-sentence.
return aside ? (
<XStack
items="center"
justify="space-between"
flexWrap="wrap"
gap="$4"
mb={gap}
>
<YStack flex={1} minW={280}>
{words}
</YStack>
{aside}
</XStack>
) : (
<YStack mb={gap}>{words}</YStack>
)
}
/** A section inside a service — smaller, same shape. */
export function Sub({ title, lede, gap = '$5' }: { title: string; lede?: ReactNode; gap?: ViewProps['gap'] }) {
return (
<YStack mb={lede ? undefined : gap}>
<Line render="h3" size="x2" weight="600" marginBottom="$2">
{title}
</Line>
{lede ? (
<Line render="p" size="sm" tone="muted" marginBottom={gap}>
{lede}
</Line>
) : null}
</YStack>
)
}
/* ── badge ─────────────────────────────────────────────────────────────────── */
/** A pill: "Most Popular", a tier name, "Free". */
export function Mark({ children }: { children: ReactNode }) {
return (
<XStack
display="inline-flex"
self="flex-start"
items="center"
gap="$1"
px="$2"
py={2}
rounded={9999}
borderWidth={1}
borderColor="$edge"
bg="$raised"
$platform-web={{ borderRadius: '9999px' }}
>
<Line px={10} tone="soft" weight="700" caps>
{children}
</Line>
</XStack>
)
}
/* ── tier ──────────────────────────────────────────────────────────────────── */
export interface Action {
label: string
/** Where the control goes. Required: a pricing control with no address is a
* button that takes the pointer and does nothing, and two of them sat under
* the rate card for months saying "Start building". */
href: string
/** A reference opens a new tab; a checkout stays in this one. */
aside?: boolean
onPress?: () => void
/** Drawn after the label. */
icon?: ReactNode
}
/**
* The control at the foot of a card: an ANCHOR, so Enter, cmd-click, middle-click
* and "copy link" all work. At `lg` it is the funnel's one button, pay's 56px
* pill at 16px (solid, or outlined); `default` is a row's own smaller pill.
*/
export function Act({
label,
href,
aside,
onPress,
icon,
solid,
size = 'lg',
}: Action & { solid?: boolean; size?: 'default' | 'lg' }) {
return (
<Pill
href={href}
fill={solid}
onClick={onPress}
{...(aside ? { target: '_blank', rel: 'noopener noreferrer' } : null)}
{...(size === 'lg' ? { minH: 56, fontSize: '1rem' } : null)}
>
{label}
{icon}
</Pill>
)
}
export function Actions({ children, gap = '$4' }: { children: ReactNode; gap?: ViewProps['gap'] }) {
return (
<XStack justify="center" flexWrap="wrap" gap={gap}>
{children}
</XStack>
)
}
/* ── rates ─────────────────────────────────────────────────────────────────── */
export interface Column {
head: string
/** Figures set to the line's end (the right, on a left-to-right page); a rate column is read against the decimal point. */
align?: 'right'
/** Set in the mono face — a price, not a sentence. */
mono?: boolean
/** Dropped below `md`, where four columns do not fit a phone. */
wide?: boolean
tone?: Tone
}
/**
* The table layout modes, as the element's own display. gui's `Display` type
* lists the flex box and its kin and not these; the runtime writes them. Every
* real `<table>` on the site states them from here.
*/
export const TABLE = 'table' as ViewProps['display']
export const ROW = 'table-row' as ViewProps['display']
export const CELL = 'table-cell' as ViewProps['display']
/**
* What a phone has no room for: a rate table's extra column. gui's `$max-md`
* drops it under 48rem; above that it is a cell again — stated, because a gui
* frame is a flex box until told otherwise and a `display: flex` cell falls out
* of its column. /pricing's rate cards, the /models catalogue and /zen/models
* all ask it; the words inside are a `Line`, since a frame takes no type.
*/
export function Spare({
render,
children,
...p
}: {
render: 'th' | 'td'
children?: ReactNode
} & Omit<ViewProps, 'render' | 'children'>) {
return (
<View render={render} display={CELL} $max-md={{ display: 'none' }} {...p}>
{children}
</View>
)
}
/**
* A rate table.
*
* Real `<table>` elements, each a gui frame that states its table display: the
* box model a frame takes by default is flexbox, so a row left to it compiles
* `display: flex` and falls out of the table. The column a phone drops is a
* `Spare`.
*/
const HAIR = 'var(--border)'
/** The mono face on a cell, which is a frame and so takes no font prop: the
* family, and the cells @hanzo/design's base keeps for the face. */
const MONO = { fontFamily: 'var(--font-mono)', fontVariantLigatures: 'none' } as const
/** A cell's room and alignment. A phone takes 12px a side, so a 320px table
* holds its widest words and pills without overflowing its rounded frame.
* The sides are the line's start and end, so a right-to-left page mirrors them. */
const cell = (col: Column, head: boolean) =>
({
px: 20,
'$max-sm': { px: 12 },
py: head ? 12 : 16,
'$platform-web': { verticalAlign: 'top', textAlign: col.align ? 'end' : 'start', ...(col.mono && !head ? MONO : null) },
}) as const
export function Rates({ columns, rows }: { columns: Column[]; rows: ReactNode[][] }) {
return (
<YStack overflow="hidden" borderWidth={1} borderColor="$edge" rounded="var(--radius-xl)">
<View render="table" display={TABLE} width="100%" $platform-web={{ borderCollapse: 'collapse', textAlign: 'start' }}>
<thead>
<View
render="tr"
display={ROW}
bg={mix('var(--neutral-900)', 30)}
borderBottomWidth={1}
borderColor="var(--neutral-800)"
>
{columns.map((c) => {
const head = (
<Line tone="muted" weight="500">
{c.head}
</Line>
)
return c.wide ? (
<Spare key={c.head} render="th" {...cell(c, true)}>
{head}
</Spare>
) : (
<View key={c.head} render="th" display={CELL} {...cell(c, true)}>
{head}
</View>
)
})}
</View>
</thead>
<tbody>
{rows.map((row, r) => (
<View key={r} render="tr" display={ROW} {...(r === rows.length - 1 ? null : { borderBottomWidth: 1, borderColor: HAIR })}>
{row.map((value, c) => {
const col = columns[c]
const body =
typeof value === 'string' || typeof value === 'number' ? (
<Line
size={col.tone === 'muted' ? 'sm' : undefined}
tone={col.tone ?? 'plain'}
weight={col.tone ? undefined : '500'}
>
{value}
</Line>
) : (
value
)
return col.wide ? (
<Spare key={c} render="td" {...cell(col, false)}>
{body}
</Spare>
) : (
<View key={c} render="td" display={CELL} {...cell(col, false)}>
{body}
</View>
)
})}
</View>
))}
</tbody>
</View>
</YStack>
)
}
/* ── included ──────────────────────────────────────────────────────────────── */
/**
* What every rung carries. A set that is true all at once, so it is swept rather
* than listed — four across where there is room, two on a phone.
*/
export function Chips({
title,
lede,
items,
}: {
title: string
lede?: ReactNode
items: string[]
}) {
return (
<Panel pad="$6">
<Line render="h3" size="xl" weight="600" marginBottom="$4">
{title}
</Line>
{lede ? (
<Line render="p" size="sm" tone="muted" marginBottom="$4">
{lede}
</Line>
) : null}
<Grid columns={{ min: 160, max: 4 }} gap={16}>
{items.map((item) => (
<Line key={item} size="sm" tone="muted">
{item}
</Line>
))}
</Grid>
</Panel>
)
}
/* ── one number ────────────────────────────────────────────────────────────── */
/** A single rate, boxed: block storage per GB, an observability overage. */
export function Stat({
title,
value,
unit,
note,
}: {
title?: string
value: ReactNode
unit?: string
note?: ReactNode
}) {
return (
<Panel pad="$5" alignSelf="flex-start">
{title ? (
<Line render="h3" size="lg" weight="600" marginBottom="$2">
{title}
</Line>
) : null}
<YStack display="block" mb="$1">
<Line size="x3" weight="700">
{value}
</Line>
{unit ? (
<Line size="lg" tone="muted">
{unit}
</Line>
) : null}
</YStack>
{note ? (
<Line render="p" size="sm" tone="muted">
{note}
</Line>
) : null}
</Panel>
)
}
/* ── layout ────────────────────────────────────────────────────────────────── */
/** The measure every rate card sits in. */
export function Wide({ children, ...rest }: { children: ReactNode; [k: string]: unknown }) {
return (
<YStack width="100%" maxW={1280} mx="auto" {...rest}>
{children}
</YStack>
)
}
/** A row of tier cards: as many as fit, never narrower than 240px. */
export function Ladder({ children, max = 4 }: { children: ReactNode; max?: number }) {
return (
<Grid columns={{ min: 240, max }} gap={24}>
{children}
</Grid>
)
}