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