Files
hanzo.ai/components/home/FooterHeroComposer.tsx
zeekay 35265a1496 /start: the composer is the first screen, said once
The page drew a heading (Start your company with one sentence) and, under
it, the composer's own (Say what to build). The composer now stands under
the page's title and line on the fold, filling the window, and the dock
still takes over once it scrolls away.
2026-10-30 01:51:11 -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 } from './band'
import { Fold } from '@/components/marketing/fold'
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)'
/**
* /start's first screen: the page's title and line over the starter chips and
* the composer, filling the window under the bar (`components/marketing/fold`),
* and the dock that stands in for the composer once it scrolls away.
*/
export function FooterHeroComposer({ title, lede }: { title: string; lede: string }) {
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 (
<>
<Fold label="Start" title={title} lede={lede}>
<YStack width="100%" maxW={840} mx="auto">
<YStack 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>
</Fold>
{/* 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 end 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={{ paddingInlineEnd: '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>
</>
)
}