Files
zeekay 87f4590da4 chrome, bands, kit: the bar and every page's column share one centred measure
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.
2026-10-29 23:46:37 -07:00

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>
)
}