Files

211 lines
7.3 KiB
TypeScript

'use client'
import { Box, Caret, Text, View, XStack } from '@hanzo/ui'
import type { ReactNode } from 'react'
import { Line } from '@hanzo/ui/marketing'
/**
* A terminal, as a figure on a page.
*
* Real text in real elements — not a screenshot, not a video, not a canvas. A
* reader can select a command out of it and paste it into their own shell,
* which is the entire point of showing a command in the first place, and it
* costs no asset, no decode and no network. It translates, it scales with the
* reader's type size, and a screen reader reads it as the transcript it is.
*
* MONOCHROME, with emphasis carried by BRIGHTNESS rather than hue. A terminal
* on a marketing page reaches for green ticks and yellow warnings, and the
* result is a page with four accent colours it never chose. Here the command is
* the brightest thing, its result is a step down, and commentary is a step below
* that — so an eye lands on what was TYPED, which is the thing a reader is
* meant to copy, and the transcript stays inside the site's palette.
*/
/**
* One line of the transcript.
*
* `run` is what a person typed. `ok` is a result the machine reported. `out` is
* plain output. `note` is dimmer still — a path, a hint, an aside. `gap` is a
* blank line, which is a real part of a transcript's rhythm and not a margin.
*/
export type Line =
| { kind: 'run'; text: string }
| { kind: 'ok'; text: string; time?: string }
| { kind: 'out'; text: string }
| { kind: 'note'; text: string }
| { kind: 'gap' }
/**
* The mono face as a terminal needs it: no ligature fusing `--` into one dash,
* and none of body's cv01-cv03/ss01/ss02, which are the sans letterforms.
*
* `$mono` puts the face's `font_mono` scope on the element, which @hanzo/design's
* base reads to drop the ligatures; the feature reset rides beside it as gui's
* own class, because the minified base merges it away.
*/
const FEATURES = { fontFeatureSettings: 'normal' } as const
/** A phone's transcript dissolves at its right edge instead of stopping against the border. */
const FADE = 'linear-gradient(90deg, var(--pure-black) calc(100% - 22px), transparent 100%)'
const INK: Record<string, number> = {
run: 0.96,
ok: 0.78,
out: 0.62,
note: 0.4,
}
/**
* A line of the transcript at its ink. `pre` so the alignment a transcript
* depends on survives, and the scroll below is what keeps a long line from
* widening the page on a phone; the `1.65em` floor matches the leading.
*/
const Row = ({ ink, children }: { ink: number; children: ReactNode }) => (
<Line
render="div"
display="flex"
items="baseline"
gap={10}
whiteSpace="pre"
color="var(--foreground)"
opacity={ink}
$platform-web={{ minHeight: '1.65em' }}
>
{children}
</Line>
)
/** The prompt a typed line starts with, a step under the line's own ink. */
const Prompt = () => (
<Text aria-hidden color="inherit" opacity={0.35}>
$
</Text>
)
/**
* The window a figure sits in: the frame, the bar, and whatever the figure is.
*
* `Term` is one tenant and a code or diagram panel is another — same frame, same
* bar, same lift — so the chrome is said once and a figure passes only its body.
* `aside` is the bar's right end, for a tag that qualifies the whole figure
* ("example") rather than any one line of it.
*/
export function Window({
title,
aside,
children,
}: {
title: string
aside?: ReactNode
children?: ReactNode
}) {
return (
<Box
rounded="var(--radius-xl, 16px)"
borderWidth={1}
borderColor="var(--border)"
bg="var(--surface-card-quiet, color-mix(in srgb, var(--neutral-800) 35%, transparent))"
overflow="hidden"
// The lift that tells an eye this is a window sitting ON the page rather
// than a block ruled into it.
boxShadow="0 24px 60px -24px color-mix(in srgb, var(--pure-black) 70%, transparent), inset 0 1px 0 var(--white-05)"
>
{/* The bar. Three dots and a name — the least chrome that still reads as a
window. No colour in the dots: a terminal is not the place this site
introduces red, amber and green. */}
<XStack items="center" gap={12} py={11} px={16} borderBottomWidth={1} borderColor="var(--border)" bg="var(--white-03)">
<XStack gap={6} aria-hidden>
{[0.22, 0.16, 0.11].map((o) => (
<View key={o} render="span" width={9} height={9} rounded="var(--radius-full)" bg="var(--foreground)" opacity={o} />
))}
</XStack>
<Line fontFamily="$mono" lineHeight="unset" fontSize="$1" color="var(--muted-foreground)" letterSpacing="0.01em" $platform-web={{ ...FEATURES }}>
{title}
</Line>
{aside ? (
<Text ml="auto" color="inherit">
{aside}
</Text>
) : null}
</XStack>
{children}
</Box>
)
}
export function Term({
title,
lines,
children,
}: {
title: string
lines: Line[]
children?: ReactNode
}) {
return (
<Window title={title}>
{/* The transcript. `overflow-x` lives HERE, on the figure's own box, so a
command too long for a 375px phone scrolls inside the window and never
widens the document — the failure that puts a sideways scrollbar on a
whole page.
The size and the inset step down on a phone. `white-space: pre` keeps
a command's alignment, so a line either fits or scrolls: `hanzo up`'s
longest output is 398px at the figure's own size and a 390 phone gives
it 316, so the phone takes the rung below, where every line of it
fits, and the right edge fades so anything longer dissolves instead of
stopping dead. The extra right inset keeps a run's timing clear of
that fade. The leading is a ratio, which a typed gui leading cannot
say. */}
<Line
render="div"
display="block"
fontFamily="$mono"
overflowX="auto"
fontSize="$1"
pt={16}
pr={26}
pb={20}
pl={16}
maskImage={FADE}
$sm={{ fontSize: '$2', pt: 18, pr: 20, pb: 22, pl: 20, maskImage: 'none' }}
lineHeight="1.65" $platform-web={{ ...FEATURES }}
>
{lines.map((line, i) => {
if (line.kind === 'gap') return <View key={i} $platform-web={{ height: '0.85em' }} />
return (
<Row key={i} ink={INK[line.kind]}>
{line.kind === 'run' ? <Prompt /> : null}
<span>{line.text}</span>
{line.kind === 'ok' && line.time ? (
// The measurement sits at the END of the line it belongs to,
// right-aligned by nothing — a transcript is not a table, and
// giving these their own column would make it one.
<Text color="inherit" opacity={0.45} ml="auto" pl={24}>
{line.time}
</Text>
) : null}
</Row>
)
})}
{/* The caret: @hanzo/ui's Caret, whose blink its motion sheet stills
for a reader who asked for no motion. */}
<Row ink={0.96}>
<Prompt />
<Caret
render="span"
aria-hidden
ml={2}
background="currentColor"
$platform-web={{ display: 'inline-block', width: '0.55em', height: '1.05em', transform: 'translateY(0.15em)' }}
/>
</Row>
</Line>
{children}
</Window>
)
}