mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
* style: the last inline styles are gui props, a kit member or @hanzo/ui
Owner rule, 2026-10-22: no style={…}, and no `inline-style:` exception.
hanzo-design.allow.json now allows 0 inline styles (from 36), 0 raw
font sizes (16), 0 raw z-indexes (1) and 42 raw colours (57).
- benchmarks/CodeRace: three lanes of hand-written divs are Lane, Track
and Dot on gui props; the hues are stated once and each wash and edge
is mixed from its hue.
- beta: the share card's picture is beta/card's Picture, used by the
share step and /beta/for/<topic>.
- /contact-sales: the options take @hanzo/ui Picker's `placeholder`
(8.29.31), which keeps the open list at full ink.
- components/code: each span says `data-code` and @hanzo/ui's new
styles/code.css inks it from the code theme; the dead Dracula
legibility pass goes with the per-token style.
- /analytics: the dashboard grows on a CSS view timeline (motion.css
`grow`) where framer wrote MotionValues per frame. A Band clips with
`overflow: clip`: `hidden` made it a scroller that never moves, and a
scroll-driven motion inside it stood half done.
- /buying-specialist: hovers and focus are hoverStyle and focusStyle
where handlers wrote them onto the element, and a reply's marks are
elements where they were HTML strings. A reply carries the name from
the address (`?name=`), which was parsed as HTML.
- ShareWidget's menu stands on --z-dropdown.
LLM.md drops the clause that allowed an `inline-style:` comment.
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
* gates/code: a block's spans say data-code and carry no style of their own
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
* CodeRace: each part of a row may shrink, as its div did; /contact-sales' Picker inks in $ink
A gui stack keeps its width where a div gave way, so at 390 the target
query and a lane's name pushed the race 136px past the window. The
sizes Picker's choice and list take $ink, as the page drew them.
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
* deps: @hanzo/ui 8.29.35 and @hanzo/design 0.5.39; code samples keep their faces
@hanzo/ui 8.29.35 marks a span's weight and slant as data-face
(codeFace), so a keyword stays bold and a comment italic with no style
on the span; 8.29.34's grow is useScroll's range exactly, and Picker's
`color` is the field's ink. @hanzo/design 0.5.39 passes no
`inline-style:` comment, and the count stays 0. The corner names
8.29.33's Plex Arabic face. The allowance's prose says the numbers it
holds (redeclared-token is 0 since the bar's rungs went).
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
* gui.css is the one @hanzo/ui 8.29.35 generates ($serif)
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
* deps: @hanzo/design stays 0.5.35
0.5.38's Arabic faces lead --font-sans with Noto Kufi Arabic, which
font.spec reads as the one binding moving; that bump is the
arabic-faces branch's to make. No inline-style comment is left, so the
zero holds under 0.5.39's rule whenever it lands.
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
243 lines
8.9 KiB
TypeScript
243 lines
8.9 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. A word passed as a string
|
|
* is drawn through `t` (`useSay`): mark it `phrase('…')` where it is written.
|
|
*/
|
|
|
|
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'
|
|
import { INSET } from '@/components/shell/dock'
|
|
import { useSay } from '@/components/marketing/say'
|
|
|
|
/**
|
|
* 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"
|
|
py={pad}
|
|
// On the page's measure and its gutter (components/shell/dock), as the bar is.
|
|
px={INSET}
|
|
borderTopWidth={rule ? 1 : 0}
|
|
borderColor="var(--border)"
|
|
background={ground}
|
|
/* It clips and never scrolls: `hidden` would make the band a scroll
|
|
container, and a scroll-driven motion inside it (motion.css `grow`)
|
|
would read the band's own scroll, which never moves, and stand half
|
|
done. An anchored band lands clear of the fixed header when a link
|
|
jumps to it. */
|
|
$platform-web={{ overflow: 'clip', ...(id ? { scrollMarginTop: 96 } : null) }}
|
|
>
|
|
<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
|
|
}) {
|
|
const say = useSay()
|
|
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>{say(eyebrow)}</Eyebrow> : null}
|
|
{title ? (
|
|
<Title quiet={!loud} {...(eyebrow ? BEAT : null)}>
|
|
{say(title)}
|
|
</Title>
|
|
) : null}
|
|
{lede ? <Lede {...(title ? BEAT : null)}>{say(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}
|
|
/>
|
|
)
|
|
}
|