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.
117 lines
4.2 KiB
TypeScript
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>
|
|
)
|
|
}
|