Files
hanzo.ai/components/shell/bar.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

117 lines
4.2 KiB
TypeScript

'use client'
import { useRef, useState, type FormEvent } from 'react'
import { useRouter } from 'next/navigation'
import { useIam } from '@hanzo/iam/react'
import { useI18n } from '@hanzo/i18n/react'
import { useAnalytics } from '@hanzo/event/react'
import { EVENTS } from '@hanzo/event'
import { useDictation, useVoice, Voice } from '@hanzo/voice'
import { ArrowUp, Mic } from 'lucide-react'
import { XStack } from '@hanzo/gui'
import { Composer, Control, Field } from '@hanzo/composer'
import { useEar, useSays } from './ear'
import { PILL } from './pill'
import { chat, keep } from './prompt'
import { enter, handoff } from '@hanzo/app/lib/destination'
/** The field's rung on the type ramp: the apex composer reads a size up. */
const TEXT_XL = 'var(--text-xl)' as never
/**
* The bar itself, loaded by ./composer and drawn in the footer, where a page ends.
*
* ONE LINE, structurally: the field is an `<input>`, which has no second row to
* grow into.
*
* The row is `[field] [mic] [send]`, all @hanzo/composer: `Composer` rings the
* pill, `Control` is the round control, `Field` is the surfaceless field. The
* pill is ./pill's `PILL`, which draws the focus ring, since the field has no
* edge of its own to ring.
*
* [mic] dictation through `@hanzo/voice`, the same microphone hanzo.chat and
* hanzo.app draw, on its browser leg. It writes what it hears into the
* field and stops; sending is still a press, because a pause in speech
* should not submit. Disabled and wearing the reason where voice
* cannot run.
* [↑] goes to chat. Signed in, straight there with the text. Otherwise
* Hanzo IAM, and the text is waiting in chat on the way back.
*/
export default function Bar() {
const { t } = useI18n()
const says = useSays()
const [value, setValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const router = useRouter()
const { isAuthenticated } = useIam()
const analytics = useAnalytics()
// Dictation writes the field; the person still presses send. Each utterance
// lands AFTER what the field held (`useDictation`), so a pause between two
// sentences never loses the first.
//
// THE PLATFORM'S EAR, on every browser: Brave ships `webkitSpeechRecognition`
// with its backend switched off, so the browser's recogniser listens and never
// answers. `useEar` is the reader's own lane signed in and the visitor's lane
// signed out; the browser stands in only when the platform refuses, and
// `<Voice/>` wears the reason.
const ear = useEar()
const dictated = useDictation(value, setValue)
const voice = useVoice({
speech: ear,
onPartial: dictated.onPartial,
onUtterance: (text) => {
dictated.onUtterance(text)
inputRef.current?.focus()
},
})
const submit = (e: FormEvent) => {
e.preventDefault()
analytics.capture(EVENTS.CHAT_STARTED, {
source: 'composer',
hasPrompt: value.trim().length > 0,
})
if (isAuthenticated) {
router.push(chat(value))
setValue('')
return
}
// A host that cannot sign in carries the ask to chat on hanzo.ai instead.
if (handoff(chat(value))) return
keep(value)
// Sign in and come back to THIS page, where the held-prompt effect above
// waits to carry the text on to chat. Without the return the ask is lost.
enter()
}
return (
<Composer render="form" {...{ onSubmit: submit }} width="100%" maxW={576} mx="auto">
{/* Glass on the pill alone. Tinting the whole band to carry one centred
control drew a shade across the bottom of every page. */}
<XStack {...PILL}>
<Field
ref={inputRef as never}
value={value}
onChangeText={setValue}
placeholder={t('Ask Hanzo anything')}
aria-label={t('Ask Hanzo 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>
<ArrowUp size={15} strokeWidth={2.5} />
</Control>
</XStack>
</Composer>
)
}