Files
hanzo.ai/components/home/band.tsx
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

260 lines
9.4 KiB
TypeScript

'use client'
// The gui root carries no `'use client'` of its own, so importing a primitive
// from it in a Server Component evaluates gui's module-scope `createContext` in
// the React server build, where that export does not exist — a bare
// "c is not a function" at page-data collection, naming nothing. The boundary
// belongs here, beside the import that needs it.
import { createContext, useContext, type ReactNode } from 'react'
import { Box, Text, View, XStack, YStack } from '@hanzo/ui'
import { Grid } from '@hanzo/ui/grid'
import * as ui from '@hanzo/ui/marketing'
/**
* Whether a head stands in the copy column of a `Split`. There, from 1024px,
* the head is set to the column's starting edge (the right one on a
* right-to-left page) beside its figure instead of centred over the page, and
* its actions start where the words do. A context rather than a selector: the
* head and the row of actions are the components that change, so they are the
* ones that ask.
*/
const Says = createContext(false)
/**
* The measure every band on a page takes, when the page states one. Unset, a
* band reads `--container-max` (@hanzo/design: 80rem). /platform/layers sets
* 1152 so its apex bands match the deploy-plane bands under them.
*/
export const Measure = createContext<number | undefined>(undefined)
/**
* The two shapes five apex sections are made of, said once.
*
* `SystemArchitecture`, `AgentRuntime`, `LearnLoop`, `Infrastructure` and
* `OneInterface` each opened with a byte-identical frame — the same measure, the
* same gutter at the same three widths, the same centred head — written out five
* times as a cluster of utility classes. One of the five had already drifted:
* `AgentRuntime` wrapped its section in a `<>…</>` left over from a second
* section that moved out.
*
* This is NOT `page-kit`'s `Section`, and the difference is the design rather
* than an oversight. That one is left-aligned over a 896 measure and is what
* every `(marketing)` page wears; this one is centred over 1024 and belongs to
* the apex. Two shapes, two homes, neither pretending to be the other.
*/
/**
* The vertical rhythm of a centred band that is not a `Band` — Enso and Score.
* Each pads top AND bottom, so the gap a reader sees between two is twice this;
* at the old clamp's ceiling that was 264px, which reads as the page having
* ended. `max(min())` is `clamp()` spelled the way gui's space type admits.
*/
export const RHYTHM = 'max(48px, min(7vw, 88px))'
/** The page gutter the header and every band share (app/globals.css). */
export const GUTTER_X = 'var(--page-gutter)'
/** The gutter is the only thing about the frame that tracks the viewport. */
const GUTTER = { paddingHorizontal: '$4' } as const
/**
* A horizontal band of the page: the measure, the gutter and the vertical
* rhythm.
*
* `Box`, not a `YStack`, because a section is a BLOCK — `Box` states
* `display: block` and a Stack would silently make every band a flex column,
* which is the defect `@hanzo/ui`'s own note on this component records (77 of
* 225 elements on one page becoming flex containers nobody asked for).
*
* A band with an `id` is a SLIDE: it fills the screen, its content sits at the
* middle, and the page settles on its top edge. The header's clearance is not
* this component's: @hanzo/design's base pads every scroll the page makes by
* `--header`, so a margin here would clear it twice.
*/
export function Band({
id,
wide = false,
children,
}: {
id?: string
wide?: boolean
children: ReactNode
}) {
const measure = useContext(Measure)
return (
<Box
render="section"
id={id}
width="100%"
maxW={wide ? 1240 : measure ?? 'var(--container-max, 1024px)'}
mx="auto"
/* The rhythm between sections is what a reader feels as calm, and it is
the one measurement that has to be generous: 120 on a desktop, 80 on a
phone — the old lux.exchange fold's gap. Tighter and the page reads as
a list of claims; this reads as one thing with room around it. */
py={120}
{...GUTTER}
$sm={{ px: '$5', py: 80 }}
$lg={{ px: '$6' }}
minH="100svh"
display="flex"
justify="center"
$platform-web={id ? { scrollSnapAlign: 'start' } : undefined}
>
{/* A BLOCK, so every child takes the band's width and a grid among them
lays its tracks out against a definite size. As flex items, a child
with auto side margins would shrink to its content instead. */}
<Box width="100%">{children}</Box>
</Box>
)
}
/**
* The head of a band: an optional label, the heading, an optional paragraph.
*
* A GRID with one column and a gap, not three elements each carrying a margin
* toward its neighbour. The spacing between them is a property of the STACK —
* stated once, by the thing that owns the relationship — and a margin on a child
* is the same fact written from the wrong side, which is why it used to be three
* different classes (`mb-5`, nothing, `mt-5`) describing one 20px rhythm.
*
* Type is @hanzo/ui's `Eyebrow`, `Title` and `Lede` and spacing is this
* component's, so a call site passes words and never a size.
*
* A BAND OPENS ON THREE THINGS AT MOST. There was a fourth — a second heading
* under the title — and where it was used it read almost as loud as the h2 and
* argued with it, over a lede that then said the same claim a third time. A name
* for a band belongs in the eyebrow, above the title, where it introduces rather
* than competes.
*/
export function Head({
eyebrow,
title,
lede,
children,
}: {
eyebrow?: string
title: string
lede?: string
children?: ReactNode
}) {
const says = useContext(Says)
return (
<Grid
columns={1}
gap={14}
width="100%"
maxW={672}
mx="auto"
$platform-web={{ textAlign: 'center' }}
$lg={says ? { mx: 0, '$platform-web': { textAlign: 'start' } } : undefined}
>
{/* The grid's gap is the beat between the three. */}
{eyebrow ? <ui.Eyebrow>{eyebrow}</ui.Eyebrow> : null}
<ui.Title>{title}</ui.Title>
{lede ? <ui.Lede>{lede}</ui.Lede> : null}
{children}
</Grid>
)
}
/**
* A card's two lines: what it is, then what it does.
*
* The pair appears on a layer, a step and a runtime — three sections, one
* shape. Sizes are the ramp's own rungs, so a reader's type-size preference
* reaches them.
*
* ONE ELEMENT, not a fragment — and the difference is visible the first time
* this is used the way its own description implies.
*
* It used to return two loose siblings, which is fine inside a container that
* wraps each item and wrong inside every grid: a grid lays out CHILDREN, so a
* three-column row of these produced six cells and each card's heading landed
* in a different column from its own body. Measured on the built page — the
* heading "Connect once" sat over the previous card's text.
*
* A component documented as "a card's two lines" should be a card. Wrapping it
* here means no caller has to know to do it, and the pairing cannot come apart
* again in the next layout it is dropped into.
*/
export function Note({ title, body }: { title: ReactNode; body: string }) {
return (
<YStack>
<Text fontSize="$3" fontWeight="500" lineHeight="1.3125rem" color="$ink">
{title}
</Text>
<Text mt={4} fontSize="$2" lineHeight="1.3203rem" color="var(--muted-foreground)">
{body}
</Text>
</YStack>
)
}
/**
* The page's one button, outlined by default and filled when it is the thing to
* do. @hanzo/ui's `Action` carries the shape; this carries the link and the
* press it reports.
*/
export function Action({
href,
filled,
onPress,
children,
}: {
href: string
filled?: boolean
/** Reports the press; `data-ga` tells the click-out handler (app/providers.tsx) the control reports its own. */
onPress?: () => void
children: ReactNode
}) {
return (
// The funnel's one button: pay's 56px pill at 16px (hanzo-inc/pay `.pay-cta`).
<ui.Action href={href} fill={filled} onPress={onPress} data-ga={onPress ? '' : undefined} minH={56} fontSize="1rem">
{children}
</ui.Action>
)
}
/**
* A row of actions, centred and allowed to wrap.
*
* This one stays FLEX, and deliberately: a pair of buttons is a single-axis run
* whose items size to their own words, which is the one thing flex is for.
* Making it a grid would give two buttons of different lengths one equal track
* each and stretch them to match — a worse picture, arrived at by counting a
* property rather than reading the layout.
*/
export function Actions({ children, top = 36 }: { children: ReactNode; top?: number }) {
const says = useContext(Says)
return (
<XStack mt={top} flexWrap="wrap" items="center" justify="center" gap="$3" $lg={says ? { justify: 'flex-start' } : undefined}>
{children}
</XStack>
)
}
/**
* Copy beside a figure from 1024px — the copy taking 0.8 of the row and the
* figure 1.2 — and stacked, copy first and centred, below it.
*/
export function Split({ says, children }: { says: ReactNode; children: ReactNode }) {
return (
<View
display="grid"
gridTemplateColumns="minmax(0, 1fr)"
gap={40}
items="center"
width="100%"
$lg={{ gridTemplateColumns: 'minmax(0, 0.8fr) minmax(0, 1.2fr)', gap: 56 }}
>
<Says.Provider value>
<YStack minW={0}>{says}</YStack>
</Says.Provider>
<YStack minW={0}>{children}</YStack>
</View>
)
}