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