Files
zeekay 3e01d979b7 Light theme: the black bands and white page ink that made /payments, /analytics, /commerce and /kai/guide unreadable follow the theme
INK, the band that drops to true black on dark, stayed black on a light
page while its headings and paragraphs took the light theme's dark ink:
1.06:1 on /payments, /analytics and /kai/guide, and /commerce's use
cases sat on --black the same way. INK is light-dark() now, the quiet
card surface on light and pure black on dark as before, and /commerce
and /kai/guide name it instead of a black of their own. Sixteen page
roots inked their whole tree --white, so anything that inherits (card
headings, bullets) was white on light; they take --foreground, the ink
the rest of the site is set in. Measured with a contrast walk over
fourteen of those pages in light: 57 runs under 3:1 on /analytics and
13 on /payments before, 7 left across all of them (chart labels and two
install chips), and none new on dark.
2026-10-18 17:07:46 -07:00

237 lines
8.4 KiB
TypeScript

'use client'
/**
* The three shapes every (marketing) product page is stacked out of.
*
* /enso, /team, /analytics and /payments each wrote the same section frame out
* by hand at every section — `border-t border-neutral-900 px-4 py-24 sm:px-6
* lg:px-8` around `mx-auto max-w-7xl` — the same centred head under it, and the
* same hairline card inside it, forty-odd times between them. They had already
* drifted: four measures for one column of cards, two rules (`neutral-900` and
* `border-border`), and four eyebrow sizes for one label.
*
* This is NOT `components/home/band.tsx`, and the difference is the design.
* That one is the APEX band: 1024, no rule, and it belongs to a page that reads
* as one continuous argument. This one is the PRODUCT band: a hairline between
* sections, a wider measure because these pages carry three- and four-up card
* grids, and its own type ladder one rung under the apex's. Two shapes, two
* homes, neither pretending to be the other — the same split `page-kit`'s
* `Section` already records against that file.
*
* Type is not a parameter. `eyebrow` / `title` / `lede` are @hanzo/design's
* roles, so a call site passes WORDS and never a size.
*/
import type { ComponentProps, ReactNode } from 'react'
import { motion } from '@/components/motion'
import { Box, Text } from '@hanzo/ui'
import { Grid } from '@hanzo/ui/grid'
import { Eyebrow, Lede, Line, Title, type LineProps } from '@hanzo/ui/marketing'
/**
* A section of a product page: the rule above it, the rhythm around it, the
* gutter beside it and the measure it centres on.
*
* The breakpoints are stated INLINE and never spread in from a shared object.
* JSX assigns whole keys, so a `{...GUTTER}` carrying its own `$sm` replaces
* whatever `$sm` an element already had — the element is then right at 390px
* and silently wrong at 640. One `$sm` per element is the only spelling that
* cannot do that, which is why this is a component and not a constant.
*/
export function Band({
id,
/** The hairline above. A hero, or a band that separates itself by ground, takes none. */
rule = true,
/** How wide the content may get. 1280 unless the grid wants less. */
measure = 1280,
/** The vertical rhythm, as a space token. */
pad = '$12',
/**
* The band's own ground, where one band is told from the next by colour
* rather than by a rule. It is a `background` because half of them are
* gradients, which no style prop can say.
*/
ground,
children,
}: {
id?: string
rule?: boolean
measure?: number
pad?: '$10' | '$11' | '$12' | '$14'
ground?: string
children: ReactNode
}) {
return (
<Box
render="section"
id={id}
/* Positioned, so a wash or a decorative overlay inside it has this band
to measure against rather than whatever happens to be positioned
further up the page. */
position="relative"
overflow="hidden"
py={pad}
px="$4"
$sm={{ px: '$5' }}
$lg={{ px: '$6' }}
borderTopWidth={rule ? 1 : 0}
borderColor="var(--border)"
background={ground}
/* An anchored band lands clear of the fixed header when a link jumps to it. */
$platform-web={id ? { scrollMarginTop: 96 } : undefined}
>
<Box width="100%" maxW={measure} mx="auto">
{children}
</Box>
</Box>
)
}
/**
* The two grounds these pages fade between, so a page names the fade rather
* than writing it out. `FADE` is the section that lifts off the page ground;
* `INK` is the one that drops to true black under it — on dark. On light it
* steps the other way, to the quiet card surface, because the type in it
* follows the theme: a black band under dark ink was unreadable on /payments,
* /analytics and /commerce for anyone on a light system.
*/
export const FADE = 'linear-gradient(to bottom, var(--background), color-mix(in oklab, var(--muted) 20%, transparent))'
export const FADE_UP = 'linear-gradient(to top, var(--background), color-mix(in oklab, var(--muted) 20%, transparent))'
export const INK = 'light-dark(var(--surface-card-quiet), var(--pure-black))'
/** A heading's lines sit one beat apart, on top of the grid's gap: the label
* over the title, the title over its paragraph. */
const BEAT = { mt: '$3.5' } as const
/**
* The head of a band: an optional label, the heading, an optional paragraph.
*
* A one-column GRID with a gap, not three elements each reaching toward its
* neighbour with a margin. The spacing between them is a property of the
* STACK — stated once, by the thing that owns the relationship — which is what
* `mb-3` / `mt-4` / `mb-14` were saying three times from the wrong side.
*
* It carries its OWN entrance, because every one of its call sites wanted the
* same one and wrapped it in an identical `motion.div` to get it.
*
* `data-quiet` on the title is the rung: a product page's section heading sits
* under that page's own `h1`, so it takes 3xl/4xl rather than the page-heading
* 4xl/5xl.
*/
const RISE = {
initial: { opacity: 0, y: 20 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true },
transition: { duration: 0.5 },
} as const
export function Head({
eyebrow,
title,
lede,
/**
* The upper rung of the same role — 4xl/5xl instead of 3xl/4xl. A page whose
* sections carry the argument takes it; a page whose sections sit under a
* louder one above them does not. `Title`'s `quiet` is the switch, so this is
* the two rungs the component already has and not a third.
*/
loud,
measure = 760,
children,
}: {
eyebrow?: string
title?: ReactNode
lede?: ReactNode
loud?: boolean
measure?: number
children?: ReactNode
}) {
if (!eyebrow && !title && !lede && !children) return null
return (
<motion.div {...RISE}>
<Grid columns={1} gap={16} width="100%" maxW={measure} mx="auto" mb={56} $platform-web={{ textAlign: 'center' }}>
{eyebrow ? <Eyebrow>{eyebrow}</Eyebrow> : null}
{title ? (
<Title quiet={!loud} {...(eyebrow ? BEAT : null)}>
{title}
</Title>
) : null}
{lede ? <Lede {...(title ? BEAT : null)}>{lede}</Lede> : null}
{children}
</Grid>
</motion.div>
)
}
/**
* The hairline tile these pages put everything in: a 16px corner, the
* hairline edge and the faint card ground, at 28px of padding.
*
* Every value is a plain prop, so a card that wants a brighter edge or more
* room states that one prop and inherits the rest. No breakpoint lives in the
* default, which is what makes overriding it by spread safe.
*/
export function Card(props: ComponentProps<typeof Box>) {
return (
<Box
rounded="var(--radius-2xl)"
borderWidth={1}
borderColor="var(--border)"
bg="$panel"
p={28}
{...props}
/>
)
}
/**
* An icon on its own disc — the mark that opens a card. Fifteen cards across
* three pages drew it as the same five utilities, and the icon inside took its
* ink from a sixth; here the disc carries the ink so the icon inherits it and a
* call site passes the mark and nothing else.
*/
export function Disc({ children }: { children: ReactNode }) {
return (
<Text
display="inline-flex"
self="flex-start"
items="center"
justify="center"
p="$3"
mb="$4"
rounded="var(--radius-full)"
bg="$hover"
color="$ink"
>
{children}
</Text>
)
}
/** The elements that sit in a line of text rather than making a box of their own. */
const INLINE = new Set(['span', 'a', 'em', 'strong', 'code', 'small'])
/** The elements @hanzo/design's base sheet inks itself: the headings and the link. */
const PRIMARY = new Set(['h1', 'h2', 'h3', 'h4', 'a'])
/**
* Words with no opinion of their own: `Line`'s loose props without `Line`'s
* defaults. Its ink and its white space are the element's — gui's `Text`
* paints `$color` and breaks pre-wrap, where a card's `<p>` inherits both and
* an `<h3>` or an `<a>` takes design's `--text-primary` — and it is a block
* unless the element is one that sits inside a line. A call site states the
* type and the spacing it changes and nothing else.
*/
export function Words({ render = 'span', ...props }: LineProps) {
const tag = typeof render === 'string' ? render : ''
return (
<Line
render={render}
display={INLINE.has(tag) ? undefined : 'block'}
color={PRIMARY.has(tag) ? 'var(--text-primary)' : 'inherit'}
whiteSpace="inherit"
{...props}
/>
)
}