mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
On a wide screen the bar spanned the window while the fold, the bands and the kit sections each centred a different width (1320, 1280, 1240, 1120) inside a different gutter ( to , or the page gutter), so the wordmark, a heading and a card's edge stood on four lines. components/shell/dock states the measure once (1320, the landing's) and INSET, the padding that holds a full-width element's content to it: the page gutter, and past the measure half of what is left beside it. The bar's row, its menu columns, the footer, both band kits, page-kit's sections, the landing's sections and Splash take it; the fold already centred 1320 in the gutter. A narrower column (an article at 860, a head at 672) stays centred inside it. Measured on the export: the h1's start, the footer's start and the bar's ends share one line at 390, 1024, 1440, 1920 and 2560.
259 lines
9.4 KiB
TypeScript
259 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'
|
|
import { INSET } from '@/components/shell/dock'
|
|
|
|
/**
|
|
* 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)'
|
|
|
|
|
|
/**
|
|
* 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%"
|
|
/* 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}
|
|
// On the page's measure and its gutter (components/shell/dock), as the bar is.
|
|
px={INSET}
|
|
$sm={{ py: 80 }}
|
|
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%" maxW={wide ? undefined : measure ?? 'var(--container-max, 1024px)'} mx="auto">
|
|
{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>
|
|
)
|
|
}
|