Files
hanzo.ai/components/home/FooterHeroComposer.tsx
zeekay 0f5536b707 hanzo.ai reads in the reader's language, and Arabic right to left
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.
2026-10-22 19:54:31 -07:00

294 lines
11 KiB
TypeScript

'use client'
import { useState, useEffect, useRef, type FormEvent } from 'react'
import { useRouter } from 'next/navigation'
import { useAnalytics } from '@hanzo/event/react'
import { useI18n } from '@hanzo/i18n/react'
import { EVENTS } from '@hanzo/event'
import { useDictation, useVoice, Voice } from '@hanzo/voice'
import { ArrowUp, Mic, BookOpen } from 'lucide-react'
import { Text, View, XStack, YStack } from '@hanzo/ui'
import { Composer, Control, Field } from '@hanzo/composer'
import { useEar, useSays } from '@/components/shell/ear'
import { PILL } from '@/components/shell/pill'
import { Action, Actions, Band, Head } from './band'
import { signUp, goToChat } from './nav-data'
import { dock, DOCK_FADE, DOCK_FOOT, DOCK_TOP } from '@/components/shell/dock'
import { CHIPS, PROMPTS } from './prompts'
import { handoff } from '@hanzo/app/lib/destination'
/** The field's rung on the type ramp: the ask reads a size up. */
const TEXT_XL = 'var(--text-xl)' as never
/** A starter chip's label size: the ramp's own rung. */
const TEXT_SM = 'var(--text-sm)' as never
/**
* The pill: the chrome's glass (shell/pill's `PILL`) with a hairline, so the
* composer reads as a control over the page and over the fold, in either theme.
*/
const EDGE = { borderWidth: 1, borderColor: 'var(--border)' } as const
/** The docked pill's drop and hairline, under the page it floats over. */
const DROP = '0 8px 32px color-mix(in srgb, var(--pure-black) 45%, transparent), 0 0 0 1px var(--white-08)'
export function FooterHeroComposer() {
const { t } = useI18n()
const says = useSays()
const router = useRouter()
const [value, setValue] = useState('')
const [promptIndex, setPromptIndex] = useState(0)
const [isFixed, setIsFixed] = useState(false)
const [isDockedAtBottom, setIsDockedAtBottom] = useState(false)
/** The in-page composer the dock stands in for. */
const composer = useRef<HTMLFormElement | null>(null)
const analytics = useAnalytics()
useEffect(() => {
const timer = setInterval(() => {
setPromptIndex((prev) => (prev + 1) % PROMPTS.length)
}, 3500)
return () => clearInterval(timer)
}, [])
// THE DOCK STANDS IN FOR THE COMPOSER, so it is the composer's own visibility
// that decides — asked of the element, never guessed from a distance.
//
// It was two pixel thresholds: show past 320px of scroll, hide within 320px of
// the document's bottom. Neither number knows where the composer is, and the
// footer's height is what sits between them — so the same rule showed BOTH at
// 1440 and 768 and NEITHER at 1024, 870 and 390, where the footer is taller
// than the guess. Measured on the shipped export at those five widths.
//
// An observer on the real element cannot drift: the dock appears exactly when
// the thing it substitutes for is off screen.
useEffect(() => {
const here = composer.current
if (!here) return
// Two reasons to step aside, each asked of its own element: the composer
// the dock stands in for is on screen, or something marked `data-clear` —
// the fold's drawn product — reaches into the band the dock covers.
const aside = new Set<Element>()
const note = (entries: IntersectionObserverEntry[]) => {
for (const e of entries) {
if (e.isIntersecting) aside.add(e.target)
else aside.delete(e.target)
}
setIsDockedAtBottom(aside.size > 0)
}
// A composer half on screen is still reachable, so the dock stays away
// until it has genuinely left.
const watch = new IntersectionObserver(note, { threshold: 0 })
watch.observe(here)
// The band is the screen's bottom 15%: the dock and the fade above it.
const band = new IntersectionObserver(note, { rootMargin: '-85% 0px 0px 0px', threshold: 0 })
document.querySelectorAll('[data-clear]').forEach((el) => band.observe(el))
// The dock is pinned to the bottom of the screen from the first pixel, and
// steps aside only while one of the two is true.
setIsFixed(true)
return () => {
watch.disconnect()
band.disconnect()
}
}, [])
const voice = useVoice({ speech: useEar(), ...useDictation(value, setValue) })
const send = (e: { preventDefault: () => void }) => {
e.preventDefault()
submit()
}
const currentPlaceholder = t(PROMPTS[promptIndex])
const shown = isFixed && !isDockedAtBottom
const submit = (e?: FormEvent, customPrompt?: string) => {
if (e?.preventDefault) e.preventDefault()
const promptToSend = (customPrompt || value.trim() || currentPlaceholder).trim()
try {
analytics?.capture?.(EVENTS.CHAT_STARTED, {
source: 'footer_composer',
hasPrompt: promptToSend.length > 0,
})
} catch {}
const target = promptToSend ? `/chat?q=${encodeURIComponent(promptToSend)}` : '/chat'
// A host that cannot sign in asks on hanzo.ai instead (lib/host.ts `guest`).
if (handoff(target)) return
if (router?.push) {
router.push(target)
} else {
goToChat(promptToSend)
}
}
return (
<>
<Band id="get-started">
<YStack width="100%" maxW={840} mx="auto">
<Head
eyebrow={t('Start')}
title={t('Say what to build.')}
lede={t('One sentence starts an app, an agent, or a whole company.')}
/>
<YStack mt={28} gap={16}>
{/* Quick Starters Chips */}
<XStack flexWrap="wrap" justify="center" gap={8}>
{CHIPS.map((chip) => (
<View
key={chip.label}
render="button"
onClick={() => submit(undefined, chip.prompt)}
px={12}
py={6}
rounded={9999}
bg="var(--glass)"
borderWidth={1}
borderColor="var(--border)"
cursor="pointer"
hoverStyle={{ bg: '$raised', borderColor: 'var(--border-selected)' }}
transition="quickest"
>
<Text fontSize={TEXT_SM} fontWeight="500" color="var(--text-secondary)">
{t(chip.label)}
</Text>
</View>
))}
</XStack>
{/* Composer Input Box */}
<Composer
render="form"
ref={composer as never}
{...{ onSubmit: (e: FormEvent) => submit(e) }}
control={38}
width="100%"
maxW={576}
mx="auto"
>
<XStack {...PILL} {...EDGE}>
<Field
value={value}
onChangeText={setValue}
onKeyDown={(e: { key: string; preventDefault: () => void }) => {
if (e.key === 'Enter') {
e.preventDefault()
submit()
}
}}
placeholder={currentPlaceholder}
aria-label={t('Ask anything or describe what to build')}
fontSize={TEXT_XL}
px={12}
/>
<Control asChild>
<Voice voice={voice} says={says}>
{(state) => <Mic size={15} fill={state === 'idle' ? 'none' : 'currentColor'} />}
</Voice>
</Control>
<Control type="submit" aria-label={t('Send')} fill onClick={send}>
<ArrowUp size={15} strokeWidth={2.5} />
</Control>
</XStack>
</Composer>
{/* The ask closes the way the fold opened: Get started is the
sign-up, the one door Try Hanzo also opens. Chat is not offered
here: the composer directly above IS the chat. The setup prompt
for a coding agent is in the fold and the agent row. */}
<Actions top={12}>
<Action href={signUp()} filled>
{t('Get started')}
</Action>
<Action href="https://docs.hanzo.ai">
<BookOpen size={15} color="currentColor" aria-hidden />
{t('Docs')}
</Action>
</Actions>
</YStack>
</YStack>
</Band>
{/* THE COMPOSER'S BAND, fixed at the bottom edge. It sits on the page
gutter, so the one control at the bottom edge is inset by the same rule
as the bar at the top; 3px above and the foot's inset below wrap the
pill closely and still clear the focus ring. No band material: the pill
carries its own glass. On a phone the pill is nearly the window's width,
so it clears the help corner at the start edge (components/shell/help.tsx:
a 32px mark 10px in). It stands on design's dock rung, under the cookie
banner, which stays answerable over it. */}
<YStack
ref={(el: unknown) => dock(el instanceof HTMLElement ? el : null)}
data-dock
position="fixed"
l={0}
r={0}
b={0}
z="var(--z-dock)"
paddingInline="var(--page-gutter)"
pt={DOCK_TOP}
pb={DOCK_FOOT}
$max-md={{ paddingInlineStart: 'calc(var(--page-gutter) + 32px)' }}
opacity={shown ? 1 : 0}
y={shown ? 0 : 16}
pointerEvents={shown ? 'auto' : 'none'}
transition="quicker"
>
{/* WHAT THE BAND CARRIES INSTEAD OF A GROUND. The composer rides every
scroll position, so a line of type passes under it. A fade to the
page's own ground is not a seam anywhere: invisible over empty page,
it dissolves the line as it passes. Behind the pill, deaf to the pointer. */}
<View
position="absolute"
t={`calc(-1 * ${DOCK_FADE})`}
r={0}
b={0}
l={0}
z={-1}
pointerEvents="none"
backgroundImage="linear-gradient(to top, var(--background) 42%, transparent)"
/>
<Composer
render="form"
{...{ onSubmit: (e: FormEvent) => submit(e) }}
control={38}
width="100%"
maxW={576}
mx="auto"
>
{/* Floating over the page, so the pill carries its own drop. */}
<XStack
{...PILL}
{...EDGE}
boxShadow={`${DROP}, 0 0 0 2px transparent`}
focusWithinStyle={{ boxShadow: `${DROP}, 0 0 0 2px var(--ring)` }}
>
<Field
value={value}
onChangeText={setValue}
placeholder={currentPlaceholder}
aria-label={t('Ask anything')}
fontSize={TEXT_XL}
px={12}
/>
<Control asChild>
<Voice voice={voice} says={says}>
{(state) => <Mic size={15} fill={state === 'idle' ? 'none' : 'currentColor'} />}
</Voice>
</Control>
<Control type="submit" aria-label={t('Send')} fill onClick={send}>
<ArrowUp size={15} strokeWidth={2.5} />
</Control>
</XStack>
</Composer>
</YStack>
</>
)
}