Files
zeekay 44626a40c3 chrome: the bar is glass on every page, and the beta strip is blade steel
The bar stood in the page's flow, clear at rest, so a first screen whose
ground is a picture or a light started below it, and the bar read as a
black band over the html's #000 ground: above the creative models'
paintings, the kit pages' glow and the lit product heroes. It is glass now
at rest and scrolled ($background/72 under the chrome blur; the scroll
listener is gone), and such a first screen spreads `under(pt)` from
components/shell/dock.ts: up by the bar's height, the same back on its top
padding, so its ground starts at the top of the window behind the glass and
no word moves. On Splash (the six creative models and Zen), PageHero, /edge,
/identity, /functions, /playground, /operative, /realtime and /enso. On
/beta the site's bar at rest keeps the glass and paints the focused pages'
fixed glow on it (components/shell/focus.tsx), so the light runs on
unbroken. 72% is kept: over Zen's painting the nav's ink holds 5.1:1 at .72
and falls to 3.7:1 at .6.

The beta strip is polished curved steel in CSS gradients: a faint top line,
a ridge 5px down, the flat dark under the words, a temper line and the
bright 1px edge at the bottom; one `/` light at 70 degrees, resting
mid-strip, moved by transform, brighter where it crosses the ridge and the
edge than under the words. The 1px/3px grain that read as stripes is gone,
and so is the root script that set --beta-tilt on <html> every pointer
frame on every page, restyling the whole document and ignoring reduced
motion. White words hold 5.4:1 against the brightest steel under them,
where the live strip measured 4.35:1 at 1440 and 3.45:1 on a phone.

Gates: e2e/gates/glass.spec.ts and e2e/gates/steel.spec.ts. The composer
gate reads its rest once the theme's colours have eased in: read straight
after the input appeared, the pill's border was a value on its way to its
token (rgba(137…), rgba(175…) on two CI runs) and failed the check that the
border keeps one value through focus.

Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
2026-10-22 12:02:12 -07:00

391 lines
14 KiB
TypeScript

'use client'
/**
* page-kit — the shared shapes every simple marketing page is made of.
*
* Pages in `app/(marketing)` used to open with a byte-identical hero block, each
* with its own copy of the same badge/title/subtitle markup and motion config.
* This module is that block, once. A page supplies content; the kit owns layout,
* spacing and the same type the homepage uses.
*
* It is built on `@hanzo/gui` — the same substrate as @hanzo/ui's product layer,
* so these shapes render from the SAME tokens as the rest of the ecosystem
* (`lib/gui.ts` is @hanzo/ui's token table, the fleet's). Pages
* therefore MUST NOT reach around the kit with their own styling: pass content,
* not styling.
*/
import React from 'react'
import Link from 'next/link'
import { Pulse, YStack, XStack, Text, View } from '@hanzo/ui'
import { Grid } from '@hanzo/ui/grid'
import { Display, Document } from '@hanzo/ui/marketing'
import { under } from '@/components/shell/dock'
/**
* Any icon the kit can render.
*
* Typed by what the kit USES — it only ever sets `size` and `color` — not by the
* icon vendor. `LucideIcon` was the wrong shape for the slot: it is lucide's own
* `ForwardRefExoticComponent`, so a structurally identical icon from anywhere
* else (the `CloudIcon` in `lib/data/cloud-primitives.ts`, say) failed on
* `$$typeof` despite rendering perfectly. Every lucide icon still satisfies this.
*/
export type KitIcon = React.ComponentType<{ size?: number | string; color?: string }>
/**
* The page gutter + centred measure every block shares.
*
* `$sm`, not `$gtSm`: the v4 media set is mobile-first, so a bare breakpoint
* name IS the min-width query and the `gt`-prefixed v3 names no longer exist.
* They are not rejected inside a spread, so the wider gutter simply never
* applied — the page ran at the 16px mobile gutter on every viewport.
*/
const GUTTER = { px: '$4', $sm: { px: '$8' } } as const
const MEASURE = { width: '100%', maxW: 1120, mx: 'auto' } as const
/* ── hero ──────────────────────────────────────────────────────────────────── */
/**
* The atmosphere behind a page's opening: a soft glow of the accent from the
* top edge, and a faint ruled field under it that fades out before the copy
* ends. Two absolute layers under the hero's content; the hero clips them.
* The apex hero (components/home/Hero.tsx) wears the same two.
*/
export function Aura() {
return (
<>
<View
aria-hidden
position="absolute"
t={0}
l="50%"
x="-50%"
width="100%"
maxW={1024}
height={480}
backgroundImage="radial-gradient(ellipse 60% 50% at 50% 15%, color-mix(in srgb, var(--primary) 12%, transparent) 0%, color-mix(in srgb, var(--primary) 3%, transparent) 50%, transparent 80%)"
filter="blur(48px)"
pointerEvents="none"
z={0}
/>
<View
aria-hidden
position="absolute"
inset={0}
backgroundImage="linear-gradient(to right, color-mix(in srgb, var(--foreground) 3.5%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--foreground) 3.5%, transparent) 1px, transparent 1px)"
backgroundSize="64px 64px"
maskImage="linear-gradient(to bottom, transparent 0px, transparent calc(var(--header, 60px) + env(safe-area-inset-top, 0px)), black calc(var(--header, 60px) + env(safe-area-inset-top, 0px) + 60px), black 75%, transparent 100%)"
pointerEvents="none"
z={0}
/>
</>
)
}
/** The kicker over a page's title: a glass pill with a live dot in it. */
function Kicker({ icon: Icon, children }: { icon?: KitIcon; children: string }) {
return (
<XStack
render="p"
self="flex-start"
items="center"
gap={8}
m={0}
mb={16}
pt={4}
pb={4}
pl={10}
pr={12}
rounded="var(--radius-full)"
borderWidth={1}
borderColor="$edge/80"
bg="$hover/70"
backdropFilter="blur(8px)"
boxShadow="var(--shadow-sm)"
>
<Pulse render="span" aria-hidden width={6} height={6} rounded="var(--radius-full)" bg="var(--primary)" boxShadow="0 0 8px var(--primary)" />
{Icon ? <Icon size={14} color="var(--text-secondary)" /> : null}
<Text fontSize="$1" lineHeight="$1" fontWeight="500" letterSpacing={1.32} textTransform="uppercase" color="var(--text-secondary)">
{children}
</Text>
</XStack>
)
}
/** The opening's copy: the kicker, the title, the sentence under it, the actions. */
function Opening({ eyebrow, icon, title, lede, children }: { eyebrow: string; icon?: KitIcon; title: string; lede?: React.ReactNode; children?: React.ReactNode }) {
return (
<>
<Kicker icon={icon}>{eyebrow}</Kicker>
{/* Tracked tighter than the role, -0.03em at every rung, so a phone's
title holds one line. */}
<Display maxW="min(16em, 100%)" letterSpacing="-0.03em">
{title}
</Display>
{lede ? (
<Text render="p" display="block" maxW="40rem" mt={18} fontSize="$4" lineHeight="$6" color="var(--muted-foreground)">
{lede}
</Text>
) : null}
{children ? (
<XStack flexWrap="wrap" items="center" gap={12} mt={28}>
{children}
</XStack>
) : null}
</>
)
}
/**
* The opening of a simple marketing page.
*
* Same face as the homepage: the kicker, @hanzo/ui's `Display`, the sentence
* under it. The title is painted at full strength on first paint — a fade from
* transparent is what made these pages look unfinished. `icon` stays in the
* props so existing pages still typecheck; it sits in the kicker, not in a
* tinted pill. Behind the copy, the `Aura`.
*/
export function PageHero({
eyebrow,
icon: Icon,
title,
lede,
visual,
children,
}: {
eyebrow: string
icon?: KitIcon
title: string
/** A sentence, or a sentence and a `Spare` clause a phone leaves out. */
lede?: React.ReactNode
visual?: React.ReactNode
children?: React.ReactNode
}) {
return (
<YStack render="section" position="relative" overflow="hidden" {...under('$10')} pb="$6" $md={{ pt: under('$14').pt, pb: '$8' }} {...GUTTER}>
<Aura />
<YStack {...MEASURE} position="relative" z={1}>
{visual ? (
<XStack
flexDirection="column"
$md={{ flexDirection: 'row', items: 'center', justify: 'space-between' }}
gap="$8"
>
<YStack flex={1} maxW={640}>
<Opening eyebrow={eyebrow} icon={Icon} title={title} lede={lede}>
{children}
</Opening>
</YStack>
<YStack flex={1} maxW={520} width="100%">
{visual}
</YStack>
</XStack>
) : (
<Opening eyebrow={eyebrow} icon={Icon} title={title} lede={lede}>
{children}
</Opening>
)}
</YStack>
</YStack>
)
}
/* ── section ───────────────────────────────────────────────────────────────── */
export function Section({
title,
lede,
children,
}: {
title?: string
lede?: string
children: React.ReactNode
}) {
return (
<YStack render="section" py="$7" {...GUTTER}>
<YStack {...MEASURE}>
{title ? (
<Text
render="h2"
mb="$2"
fontSize="$7"
fontWeight="500"
letterSpacing="$7"
color="$ink"
>
{title}
</Text>
) : null}
{lede ? (
<Text render="p" mb="$5" maxW={672} fontSize="$3" color="$soft">
{lede}
</Text>
) : null}
{children}
</YStack>
</YStack>
)
}
/* ── cards ─────────────────────────────────────────────────────────────────── */
export interface CardItem {
title: string
description: string
href?: string
icon?: KitIcon
/** Shown right-aligned in the title row — e.g. a date or an endpoint verb. */
meta?: string
}
function CardBody({ item }: { item: CardItem }) {
const { title, description, icon: Icon, meta } = item
return (
<>
<XStack mb="$2" items="center" gap="$2">
{Icon ? <Icon size={16} color="var(--muted-foreground)" /> : null}
<Text fontSize="$3" fontWeight="500" color="$ink">
{title}
</Text>
{meta ? (
<Text ml="auto" shrink={0} fontSize="$1" color="$soft">
{meta}
</Text>
) : null}
</XStack>
<Text fontSize="$3" lineHeight="$4" color="$soft">
{description}
</Text>
</>
)
}
/**
* One-, two- or three-up card grid. A real grid, so tiles in a row share a
* height rather than each sizing to its own copy.
*
* NO BREAKPOINTS, and that is the whole reason it is `Grid` now. It used to
* name three of them — one column, then two at `$sm`, then `columns` at `$lg` —
* which is a guess about the VIEWPORT standing in for a fact about the CARD.
* `{ min: 280, max: n }` states the fact directly: a card is never narrower than
* 280px and a row never wider than n, and auto-fill counts how many fit the
* measure it is actually given. That answers correctly inside a narrow column on
* a wide screen, which no viewport query can.
*
* 280 is the card's floor, not a re-spelling of the 640px breakpoint it
* replaces: below ~608px of measure the row drops to one column on its own,
* within a few dozen pixels of where the old `$sm` did, and it no longer has to
* be told twice.
*
* `gap` is the NUMBER 16, not `'$4'`. `Grid` resolves a token through
* `getTokenValue(v, 'space')` and takes it only if it comes back a number —
* gui's ramp stores `calc(16px * var(--density, 1))`, a string, so every `$n`
* silently falls back to the 12px default. It is the arithmetic behind the
* `max` floor as well as the visible gutter, so the wrong value is wrong twice.
*/
const GAP = 16
export function CardGrid({ items, columns = 2 }: { items: CardItem[]; columns?: 1 | 2 | 3 }) {
return (
<Grid columns={columns === 1 ? 1 : { min: 280, max: columns }} gap={GAP}>
{items.map((item) => {
const frame = {
p: '$5',
borderWidth: 1,
borderColor: '$edge',
rounded: '$4',
hoverStyle: { borderColor: '$rim' },
} as const
return item.href ? (
<YStack
key={item.title}
render={<Link href={item.href} {...(item.href.startsWith('http') ? { target: '_blank', rel: 'noreferrer noopener' } : null)} />}
{...frame}
>
<CardBody item={item} />
</YStack>
) : (
<YStack key={item.title} {...frame}>
<CardBody item={item} />
</YStack>
)
})}
</Grid>
)
}
/* ── call to action ────────────────────────────────────────────────────────── */
/**
* A standalone action. `minHeight={44}` is the minimum comfortable touch target,
* which padding alone does not reach at this font size. Inline links inside
* `Prose` are deliberately not this; they belong in the text flow. A View keeps
* its content's width, so `maxW` is what lets a label longer than a phone's
* column wrap inside the pill instead of running off the side.
*/
export function Cta({
href,
children,
icon: Icon,
}: {
href: string
children: React.ReactNode
icon?: KitIcon
}) {
const external = href.startsWith('http')
return (
<XStack
render={<Link href={href} {...(external ? { target: '_blank', rel: 'noreferrer noopener' } : null)} />}
self="flex-start"
maxW="100%"
minH={44}
items="center"
gap="$2"
px="$4.5"
py="$2"
borderWidth={1}
borderColor="$edge"
rounded="$10"
hoverStyle={{ borderColor: '$rim' }}
>
<Text fontSize="$3" fontWeight="500" color="$ink">
{children}
</Text>
{Icon ? <Icon size={14} color="var(--foreground)" /> : null}
</XStack>
)
}
/* ── prose ─────────────────────────────────────────────────────────────────── */
/**
* Long-form body copy (policies, explanations). Headings come from `Section`.
*
* @hanzo/ui's `Document` sets the type. `html` is a document AUTHORED as
* markdown (content/legal/*.md) and rendered at build time, which arrives as
* bare tags no prop can reach; the same element holds a tree written here.
*/
export function Prose({ children, html }: { children?: React.ReactNode; html?: string }) {
return <Document html={html}>{children}</Document>
}
/* ── page wrapper ──────────────────────────────────────────────────────────── */
/**
* The outer frame; the marketing layout already supplies nav + footer.
*
* No `backgroundColor`. The ground is html's — stated once in globals.css so
* <DotMatrix/> can sit under the document — and a wrapper repainting it is an
* opaque sheet over the backdrop for every page built out of this kit.
*/
export function Page({ children }: { children: React.ReactNode }) {
return (
<YStack minH="100vh">
<YStack render="main" pb="$10">
{children}
</YStack>
</YStack>
)
}