mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
211 lines
7.3 KiB
TypeScript
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>
|
|
)
|
|
}
|