mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Every word the landing, the header menus, the phone sheet, the footer,
the help corner and the way in (sign-in, sign-up, the reset, the
invitation, the callback, onboarding, the 404) write goes through
@hanzo/i18n's t(): one sentence is one message, its figures, names and
links arguments; copy written as data is phrase('…') where written and
t(label) where drawn. Plans, the chart, the API states and the lines
lib/kai and lib/enso build for the landing do the same.
Right to left: <html lang dir> is set before the first paint (the
package's bootScript first in the head); a figure, clock, range or
price outside a sentence is isolated; sides are logical gui props, the
layout's [data-join] corners logical, a bar grows from the start;
arrows along the line mirror (Onward for "words ->" links). The repo
dates read in the reader's language.
Gates select by slot, never by an English label (rtl, help). New gate
translated.spec.ts: in Arabic the landing at 1440 and 390, every menu
open, the phone sheet, sign-in, sign-up and the app signed in hold no
authored English, nothing scrolls sideways, every range is isolated;
French the same for English's commonest words. What another package
draws with no words prop is listed by name (@hanzo/ui's Footer label,
@hanzo/build's composer row and suggestions).
@hanzo/i18n 0.5.3 (the catalogs: 1,443 keys, every offered locale
complete), @hanzo/app 0.2.1 (the app's words). The link gate imports
the terms versions from @hanzo/app, the one SDK main now resolves.
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
|
|
import { useI18n } from '@hanzo/i18n/react'
|
|
import { Text, View, XStack, YStack, type GuiElement } from '@hanzo/ui'
|
|
import { Dots, type Field } from '@hanzo/ui/dots'
|
|
import { Lede, Title } from '@hanzo/ui/marketing'
|
|
import { Label } from './label'
|
|
|
|
/**
|
|
* The parts every landing section is drawn from: a window, the dot field behind
|
|
* it, the section's head, and the two facts an animation asks before it moves
|
|
* (is it on screen, and does the reader want motion).
|
|
*
|
|
* MOTION MOVES MARKS, NEVER COPY. A bar, a dot, a line and a clock animate; a
|
|
* word, a figure's text and a heading are drawn where they stand from the first
|
|
* frame, so a reader the observer never reaches still reads every one
|
|
* (LLM.md "A scroll reveal may not hide the content it reveals").
|
|
*/
|
|
|
|
const REDUCE = '(prefers-reduced-motion: reduce)'
|
|
const listen = (on: () => void) => {
|
|
const q = window.matchMedia(REDUCE)
|
|
q.addEventListener('change', on)
|
|
return () => q.removeEventListener('change', on)
|
|
}
|
|
|
|
/** Whether the reader asked for less motion. The server and the first paint assume still, so nothing moves before the page knows. */
|
|
export function useStill(): boolean {
|
|
return useSyncExternalStore(
|
|
listen,
|
|
() => window.matchMedia(REDUCE).matches,
|
|
() => true,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The element `ref` names has come on screen (a fifth of it, by default): true
|
|
* once and for good, so an animation plays the first time it is seen and holds
|
|
* its last frame after.
|
|
*/
|
|
export function useSeen(threshold = 0.2) {
|
|
const ref = useRef<GuiElement>(null)
|
|
const [seen, setSeen] = useState(false)
|
|
useEffect(() => {
|
|
const el = ref.current as unknown as Element | null
|
|
if (!el) return
|
|
if (typeof IntersectionObserver === 'undefined') return
|
|
const io = new IntersectionObserver(
|
|
([e]) => {
|
|
if (!e?.isIntersecting) return
|
|
setSeen(true)
|
|
io.disconnect()
|
|
},
|
|
{ threshold },
|
|
)
|
|
io.observe(el)
|
|
return () => io.disconnect()
|
|
}, [threshold])
|
|
return [ref, seen] as const
|
|
}
|
|
|
|
/** A DOM node behind a gui ref. */
|
|
export const node = (r: { current: GuiElement | null }) => r.current as unknown as HTMLElement | null
|
|
|
|
/**
|
|
* A mark that grows into place the first time it is seen: a paused animation
|
|
* from its first frame, so it waits at `from` with no style of its own, plays
|
|
* when `play` turns true, and stands at `to` for a reader who wants no motion.
|
|
*/
|
|
export function useGrow(play: boolean, still: boolean, { delay = 0, duration = 700, from = 'scaleX(0)', to = 'scaleX(1)' } = {}) {
|
|
const ref = useRef<GuiElement>(null)
|
|
const run = useRef<Animation | null>(null)
|
|
useLayoutEffect(() => {
|
|
const el = node(ref)
|
|
if (!el || typeof el.animate !== 'function') return
|
|
const a = el.animate([{ transform: from }, { transform: to }], { duration, delay, easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)', fill: 'both' })
|
|
run.current = a
|
|
if (still) a.finish()
|
|
else a.pause()
|
|
return () => a.cancel()
|
|
}, [still, delay, duration, from, to])
|
|
useEffect(() => {
|
|
if (play && !still) run.current?.play()
|
|
}, [play, still])
|
|
return ref
|
|
}
|
|
|
|
/** Where a window may be moved: a screen this wide or wider. Narrower, the windows stay stacked where the page put them. */
|
|
const ROOMY = '(min-width: 1024px)'
|
|
const roomy = (on: () => void) => {
|
|
const q = window.matchMedia(ROOMY)
|
|
q.addEventListener('change', on)
|
|
return () => q.removeEventListener('change', on)
|
|
}
|
|
/** Whether the windows may move: false on the server and the first paint, so nothing moves before the page knows. */
|
|
function useRoomy(): boolean {
|
|
return useSyncExternalStore(
|
|
roomy,
|
|
() => window.matchMedia(ROOMY).matches,
|
|
() => false,
|
|
)
|
|
}
|
|
|
|
/** The window last taken hold of is drawn above the rest. One count for the page. */
|
|
let lifted = 1
|
|
|
|
/** The pixels an arrow key moves a focused title bar; with Shift, four times as far. */
|
|
const NUDGE = 12
|
|
|
|
/**
|
|
* A window: a title bar in the page's ink with the page's ground for its words,
|
|
* a mark or icon, a mono label, and whatever the window holds. Smooth rounded
|
|
* corners with subtle depth. The bar carries no control of its own.
|
|
*
|
|
* ON A WIDE SCREEN A WINDOW MOVES. Its title bar is the handle: a pointer or a
|
|
* finger drags it, and the arrow keys nudge it while the bar has focus. It
|
|
* moves by a transform from where the page put it, held inside the
|
|
* section it belongs to, so none is lost off the page, and the one taken hold
|
|
* of comes to the front. Below 1024px the windows stay stacked and still.
|
|
* Everything inside a window stays a control of its own: only the bar drags.
|
|
*/
|
|
export function Win({
|
|
title,
|
|
aside,
|
|
icon,
|
|
children,
|
|
slot,
|
|
open,
|
|
}: {
|
|
title: string
|
|
aside?: ReactNode
|
|
icon?: ReactNode
|
|
children: ReactNode
|
|
slot?: string
|
|
/** Lets a menu inside spill past the frame; the bar then rounds its own top corners. */
|
|
open?: boolean
|
|
}) {
|
|
const { t } = useI18n()
|
|
const box = useRef<GuiElement>(null)
|
|
const wide = useRoomy()
|
|
const [at, setAt] = useState({ x: 0, y: 0 })
|
|
const [z, setZ] = useState(0)
|
|
const grab = useRef<{ id: number; x: number; y: number; from: { x: number; y: number } } | null>(null)
|
|
|
|
// Back where the page put it when the screen narrows past moving.
|
|
useEffect(() => {
|
|
if (!wide) setAt({ x: 0, y: 0 })
|
|
}, [wide])
|
|
|
|
/** An offset held inside the section the window belongs to. */
|
|
const held = (x: number, y: number) => {
|
|
const el = node(box)
|
|
const room = el?.closest('section')
|
|
if (!el || !room) return { x, y }
|
|
const r = el.getBoundingClientRect()
|
|
const c = room.getBoundingClientRect()
|
|
// Where the window stands with no offset, so the bounds do not move with it.
|
|
const left = r.left - at.x
|
|
const top = r.top - at.y
|
|
const clamp = (v: number, lo: number, hi: number) => (lo > hi ? 0 : Math.min(hi, Math.max(lo, v)))
|
|
return { x: clamp(x, c.left - left, c.right - left - r.width), y: clamp(y, c.top - top, c.bottom - top - r.height) }
|
|
}
|
|
const lift = () => setZ(++lifted)
|
|
|
|
const down = (e: React.PointerEvent<HTMLElement>) => {
|
|
if (!wide || (e.target as HTMLElement).closest('button')) return
|
|
lift()
|
|
grab.current = { id: e.pointerId, x: e.clientX, y: e.clientY, from: at }
|
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
e.preventDefault()
|
|
}
|
|
const move = (e: React.PointerEvent<HTMLElement>) => {
|
|
const g = grab.current
|
|
if (!g || g.id !== e.pointerId) return
|
|
setAt(held(g.from.x + e.clientX - g.x, g.from.y + e.clientY - g.y))
|
|
}
|
|
const up = (e: React.PointerEvent<HTMLElement>) => {
|
|
if (grab.current?.id === e.pointerId) grab.current = null
|
|
}
|
|
// gui types the event as React Native's; on the web it is the DOM's, which carries both fields.
|
|
const key = (e: { key?: string; shiftKey?: boolean; target: unknown; currentTarget: unknown; preventDefault: () => void }) => {
|
|
if (e.target !== e.currentTarget || !wide) return
|
|
const step = e.shiftKey ? NUDGE * 4 : NUDGE
|
|
const d = ({ ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] } as Record<string, number[]>)[e.key ?? '']
|
|
if (!d) return
|
|
e.preventDefault()
|
|
lift()
|
|
setAt(held(at.x + d[0], at.y + d[1]))
|
|
}
|
|
|
|
return (
|
|
<YStack
|
|
ref={box}
|
|
data-slot={slot}
|
|
data-win={title}
|
|
position="relative"
|
|
z={z || undefined}
|
|
minW={0}
|
|
borderWidth={1}
|
|
borderColor="var(--border-strong)"
|
|
bg="var(--background)"
|
|
rounded={20}
|
|
overflow={open ? 'visible' : 'hidden'}
|
|
boxShadow="0 28px 60px -32px color-mix(in srgb, var(--pure-black) 80%, transparent)"
|
|
$platform-web={{ borderRadius: '20px', ...(at.x || at.y ? { transform: `translate3d(${at.x}px, ${at.y}px, 0)` } : null) }}
|
|
>
|
|
<XStack
|
|
items="center"
|
|
gap={10}
|
|
px={12}
|
|
py={7}
|
|
bg="var(--foreground)"
|
|
borderTopLeftRadius={open ? 19 : undefined}
|
|
borderTopRightRadius={open ? 19 : undefined}
|
|
tabIndex={0}
|
|
role="group"
|
|
aria-label={wide ? t('{title} window. Drag or use the arrow keys to move it.', { title }) : t('{title} window', { title })}
|
|
data-handle=""
|
|
cursor={wide ? 'grab' : undefined}
|
|
focusVisibleStyle={{ outlineWidth: 2, outlineStyle: 'solid', outlineColor: 'var(--ring)', outlineOffset: -2 }}
|
|
onPointerDown={down}
|
|
onPointerMove={move}
|
|
onPointerUp={up}
|
|
onPointerCancel={up}
|
|
onKeyDown={key}
|
|
$platform-web={{ color: 'var(--background)', touchAction: wide ? 'none' : 'auto', userSelect: 'none' }}
|
|
>
|
|
<View aria-hidden display="flex" items="center" justify="center" shrink={0}>
|
|
{icon ?? <View width={6} height={6} rounded={9999} bg="var(--background)" $platform-web={{ borderRadius: '9999px' }} />}
|
|
</View>
|
|
<Text fontFamily="$mono" fontSize="$1" lineHeight="$1" color="var(--background)" fontWeight="600" numberOfLines={1} flex={1} minW={0}>
|
|
{title}
|
|
</Text>
|
|
{aside ? (
|
|
<XStack px={8} py={2} rounded={9999} borderWidth={1} borderColor="var(--background)" opacity={0.75} items="center" justify="center" $platform-web={{ borderRadius: '9999px' }}>
|
|
<Text fontFamily="$mono" fontSize="$1" lineHeight="$1" color="var(--background)" numberOfLines={1}>
|
|
{aside}
|
|
</Text>
|
|
</XStack>
|
|
) : null}
|
|
</XStack>
|
|
{children}
|
|
</YStack>
|
|
)
|
|
}
|
|
|
|
/** A line of the window's mono readout: a label at the start of the line, its value at the end. */
|
|
export function Reading({ label, children, strong }: { label: string; children: ReactNode; strong?: boolean }) {
|
|
return (
|
|
<XStack justify="space-between" flexWrap="wrap" columnGap={12} py={4}>
|
|
<Text fontFamily="$mono" fontSize="$2" lineHeight="$2" color="var(--muted-foreground)">
|
|
{label}
|
|
</Text>
|
|
<Text fontFamily="$mono" fontSize="$2" lineHeight="$2" color={strong ? 'var(--foreground)' : 'var(--muted-foreground)'} text="end" $platform-web={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
{children}
|
|
</Text>
|
|
</XStack>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The dot field behind a section: a halftone of soft clouds, painted once at
|
|
* the cell pitch, and drifted as one composited layer while it is on screen.
|
|
* Moving the painted canvas, rather than repainting it, is what keeps the page
|
|
* at the display's rate: a transform costs the compositor, not the CPU.
|
|
*
|
|
* The clouds are placed against the words, so a right-to-left page, whose
|
|
* headings and promise stand at the right, draws the field mirrored
|
|
* (`data-i18n-mirror` on the frame; the drift moves the layer inside it).
|
|
*/
|
|
/** Clouds as centre and radius, in the field's 0..1 units. */
|
|
type Cloud = [number, number, number]
|
|
export const clouds =
|
|
(cs: Cloud[]): Field =>
|
|
(x, y) =>
|
|
Math.min(1, cs.reduce((v, [cx, cy, r]) => v + Math.exp(-((x - cx) ** 2 + (y - cy) ** 2) / (r * r)), 0)) ** 1.4
|
|
/** The sections': heaviest at the right and the foot, where no heading sits. */
|
|
const CLOUD = clouds([
|
|
[0.95, 0.35, 0.34],
|
|
[0.78, 0.98, 0.4],
|
|
[0.2, 1.05, 0.32],
|
|
])
|
|
|
|
export function Dither({ field = CLOUD, cell = 7, strength = 0.2 }: { field?: Field; cell?: number; strength?: number }) {
|
|
const ref = useRef<GuiElement>(null)
|
|
const still = useStill()
|
|
useEffect(() => {
|
|
const el = node(ref)
|
|
if (!el || still || typeof el.animate !== 'function') return
|
|
const drift = el.animate([{ transform: 'translate3d(0, 0, 0)' }, { transform: 'translate3d(-2.5%, 1.5%, 0)' }], {
|
|
duration: 26000,
|
|
direction: 'alternate',
|
|
iterations: Infinity,
|
|
easing: 'ease-in-out',
|
|
})
|
|
drift.pause()
|
|
const io = new IntersectionObserver(([e]) => (e?.isIntersecting ? drift.play() : drift.pause()))
|
|
io.observe(el)
|
|
return () => {
|
|
io.disconnect()
|
|
drift.cancel()
|
|
}
|
|
}, [still])
|
|
return (
|
|
<View aria-hidden position="absolute" t={0} l={0} r={0} b={0} overflow="hidden" pointerEvents="none" data-i18n-mirror>
|
|
<View ref={ref} position="absolute" t="-4%" l="-4%" width="108%" height="108%" opacity={strength} $platform-web={{ color: 'var(--foreground)' }}>
|
|
<Dots field={field} cell={cell} animate={false} fade={{ top: 0.12, bottom: 0.18, left: 0.06, right: 0.06 }} />
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
/** The first screen's field: clouds behind the figure and at the corners, clear behind the promise. */
|
|
const OPENING = clouds([
|
|
[0.98, 0.1, 0.36],
|
|
[0.7, 1.02, 0.34],
|
|
[0.02, 0.98, 0.26],
|
|
[0.62, 0.0, 0.18],
|
|
])
|
|
|
|
/**
|
|
* The dot field behind a page's first screen, denser than a section's. / opens
|
|
* on it, and so does /enso, the promise on the left and the figure on the right.
|
|
*/
|
|
export function Opening() {
|
|
return <Dither field={OPENING} strength={0.34} />
|
|
}
|
|
|
|
/**
|
|
* A section of the page: its anchor, a label in mono above the heading, the
|
|
* heading, a line under it, and the figure. Every section is a band of the
|
|
* page's measure, with the dot field behind it when it asks for one.
|
|
*/
|
|
export function Section({
|
|
id,
|
|
label,
|
|
title,
|
|
lede,
|
|
dither,
|
|
children,
|
|
}: {
|
|
id: string
|
|
label: string
|
|
title: ReactNode
|
|
lede?: ReactNode
|
|
dither?: boolean
|
|
children: ReactNode
|
|
}) {
|
|
return (
|
|
<View render="section" id={id} aria-labelledby={`${id}-title`} position="relative" width="100%" py={56} $md={{ py: 80 }}>
|
|
{dither ? <Dither /> : null}
|
|
<YStack position="relative" width="100%" maxW={1240} mx="auto" px="var(--page-gutter)" gap={40}>
|
|
<YStack gap={14} maxW={760}>
|
|
<Label>{label}</Label>
|
|
<Title id={`${id}-title`}>{title}</Title>
|
|
{lede ? <Lede>{lede}</Lede> : null}
|
|
</YStack>
|
|
{children}
|
|
</YStack>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
/** The room a figure keeps for itself while its read is on its way, so nothing below it moves when it arrives. */
|
|
export function Hold({ height, children }: { height: number; children: ReactNode }) {
|
|
return (
|
|
<YStack minH={height}>
|
|
{children}
|
|
</YStack>
|
|
)
|
|
}
|