Files

256 lines
7.9 KiB
TypeScript

'use client'
/**
* The picture kit — the one vocabulary every section on this page draws in.
*
* `Surfaces` invented all of this and held it privately: a card, an inset plate,
* a mono row, a verdict chip, a caption. Seven sections below it render nothing
* but type, and each one that grew a picture would have re-invented the same
* five things slightly differently — which is how a page comes to have four
* greys and three border radii and look assembled rather than designed.
*
* THE RULE THE PICTURES FOLLOW, and it is what makes them worth drawing: a plate
* shows the PRODUCT'S OWN OUTPUT. The primitives plate is an ops count, the
* policy plate is the decision log, the ledger plate is a bill. None of them is
* an illustration of a concept — each is a thing the platform really emits, in
* the shape it really emits it. A section whose claim has no artefact behind it
* should stay type rather than grow a drawing that implies one exists.
*
* Every part is a COMPONENT on gui props over @hanzo/design's tokens, so a plate
* retunes with the theme and never carries a colour of its own. Components and
* not shared style values, because this file is `'use client'`: a Server
* Component holding a client VALUE holds a reference, and a spread of one
* yields `{}` — the frame silently gone. A component crosses that boundary
* whole.
*/
import type { ComponentProps, ReactNode } from 'react'
import { Text, XStack, YStack } from '@hanzo/ui'
import { Grid } from '@hanzo/ui/grid'
type TextProps = ComponentProps<typeof Text>
/** The mono face every index and log line is set in: 11px on a 1.9 line. */
const MONO = { fontFamily: '$mono', fontSize: '$1', lineHeight: '1.3rem' } as const
/** A run of mono type: a value, a path, a line of a log. */
export function Mono({ children, ...rest }: TextProps) {
return (
<Text display="block" {...MONO} {...rest}>
{children}
</Text>
)
}
/** The quiet part of a line — an annotation beside the value it explains. */
export function Dim({ children }: { children: ReactNode }) {
return <Text color="var(--text-tertiary)">{children}</Text>
}
/** The line under a picture that says what the picture is. */
export function Caption({ children, ...rest }: TextProps) {
return (
<Text render="p" display="block" pt={4} fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)" {...rest}>
{children}
</Text>
)
}
/** The word over a pane that says what the pane holds. */
export function Label({ children, ...rest }: TextProps) {
return (
<Text fontFamily="$mono" fontSize="$1" letterSpacing={1.54} textTransform="uppercase" color="var(--text-tertiary)" {...rest}>
{children}
</Text>
)
}
/** The line a plate opens on: the host every row under it hangs off. */
export function Host({ children }: { children: ReactNode }) {
return (
<Mono color="var(--muted-foreground)" pb={8} borderBottomWidth={1} borderColor="$edge">
{children}
</Mono>
)
}
/** The line that divides a stack from what cuts across it. */
export function Rule({ children }: { children: ReactNode }) {
return (
<Mono color="var(--text-tertiary)" pt={8} borderTopWidth={1} borderColor="$edge">
{children}
</Mono>
)
}
/**
* A verdict, as the platform words it.
*
* `strong` is the one that carries weight — a refusal, a hold, a failure — and it
* is the FILLED one. A control that only ever says yes is not a control, so a
* page showing this vocabulary has to be willing to show the loud state, and the
* loud state has to be the one that is legible at a glance.
*
* The quiet state is `--muted-foreground` rather than a step darker: an 11px
* word on the plate's own ground measured 4.04:1, under the 4.5:1 a reader is
* owed for text this size.
*/
export function Chip({ children, strong = false }: { children: ReactNode; strong?: boolean }) {
return (
<Text
shrink={0}
rounded={4}
borderWidth={1}
borderColor="$edge"
px={6}
fontSize="$1"
color={strong ? 'var(--pure-white)' : 'var(--muted-foreground)'}
bg={strong ? 'var(--neutral-800)' : undefined}
>
{children}
</Text>
)
}
/**
* One line of a log: a fixed left, a flexible middle, a verdict.
*
* NOWRAP on the row, and the middle is the only thing allowed to truncate. A log
* line is read left to right and its shape IS the information — let it wrap and
* one event breaks into stacked fragments that read as several.
*/
export function Row({
lead,
children,
verdict,
strong = false,
struck = false,
}: {
lead: string
children: ReactNode
verdict?: ReactNode
strong?: boolean
struck?: boolean
}) {
return (
<XStack items="center" gap={8}>
<Mono shrink={0} whiteSpace="nowrap" color="var(--text-tertiary)">
{lead}
</Mono>
<Mono
flex={1}
minW={0}
whiteSpace="nowrap"
color={struck ? 'var(--neutral-600)' : 'var(--neutral-400)'}
textDecorationLine={struck ? 'line-through' : 'none'}
>
{children}
</Mono>
{verdict ? <Chip strong={strong}>{verdict}</Chip> : null}
</XStack>
)
}
/**
* A row of an index that is itself a link: quiet ink at rest, the whole row
* coming up to full ink together under a pointer or a key, and a thumb-sized
* box on a coarse pointer, since a row that navigates is a control and not a
* link in a sentence.
*/
export function LinkRow({ href, children }: { href: string; children: ReactNode }) {
return (
<XStack render={<a href={href} />} group items="center" gap={8} $touchable={{ minH: 44 }}>
{children}
</XStack>
)
}
/** A cell of a `LinkRow`: muted at rest, full ink when its row is reached. */
export function Cell({ children, ...rest }: TextProps) {
return (
<Mono
whiteSpace="nowrap"
color="var(--muted-foreground)"
transition="quickest"
$group-hover={{ color: '$ink' }}
$group-focusVisible={{ color: '$ink' }}
{...rest}
>
{children}
</Mono>
)
}
/** A plate of rows. The one container every picture opens with. */
export function Plate({ children, gap = 6 }: { children: ReactNode; gap?: number }) {
return (
<YStack rounded="var(--radius-lg)" borderWidth={1} borderColor="$edge" bg="var(--surface-card-quiet)" p={12} overflow="hidden">
<Grid columns={1} gap={gap}>
{children}
</Grid>
</YStack>
)
}
/** A card's frame: one hairline, one radius, one inset. */
const FRAME = {
rounded: 'var(--radius-xl)',
borderWidth: 1,
borderColor: '$edge',
bg: '$panel',
p: 20,
gap: 12,
} as const
/** A card's two lines: what it is, then what it does. */
export function CardTitle({ children, render }: { children: ReactNode; render?: 'h3' | 'div' }) {
return (
<Text render={render} display="flex" items="center" gap={8} fontSize="$3" fontWeight="500" color="$ink">
{children}
</Text>
)
}
export function CardBody({ children }: { children: ReactNode }) {
return (
<Text render="p" display="block" fontSize="$2" lineHeight="1.3203rem" color="var(--muted-foreground)">
{children}
</Text>
)
}
/** The same card, when the whole card is the link. */
export function CardLink({ href, children }: { href: string; children: ReactNode }) {
return (
<YStack render={<a href={href} />} {...FRAME} gap={0}>
{children}
</YStack>
)
}
/** A titled card with a picture inside it and a caption under it. */
export function Card({
icon: Icon,
title,
body,
children,
caption,
}: {
icon?: (p: { size?: number; strokeWidth?: number; 'aria-hidden'?: boolean }) => ReactNode
title: string
body?: string
children?: ReactNode
caption?: string
}) {
return (
<YStack {...FRAME}>
<CardTitle render="div">
{Icon ? <Icon size={16} strokeWidth={1.5} aria-hidden /> : null}
{title}
</CardTitle>
{body ? <CardBody>{body}</CardBody> : null}
{children}
{caption ? <Caption>{caption}</Caption> : null}
</YStack>
)
}