Files
zeekayandGitHub 7843241e3f style: the last inline styles are gui props, a kit member or @hanzo/ui (#108)
* 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
2026-10-30 06:12:18 -07:00

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