Files
zeekay 21d5a549cb The whale keeps the left, and Blue answers for the foundation too
The clip is the page rather than a picture on it, and the conversation was
sitting in the middle of it — a column of type over the animal it exists to
show. On a wide screen the column is now on the right and Blue swims in the two
thirds nobody is typing over. It moves once, on the first question: before
anyone speaks it sits wider and further in, because the greeting is the only
content on the page and a narrow ribbon at the edge is not an opening; on the
first question it settles to its talking width at the edge. Two concrete
lengths, so it animates rather than jumping. A phone has no two thirds to
spare, so below 60rem nothing changes.

Blue also answers for the foundation now, not only for the ocean — on the front
door that is most of what people ask. The counts come from the surface Blue is
standing on: zoo.ngo counts its own papers, proposals and open models from the
sources, and hands them over. Blue states those numbers and guesses none. They
arrive as three integers in a plausible range or they do not arrive at all,
because anything a URL can carry reaches a prompt.
2026-09-07 17:56:59 -07:00

228 lines
8.4 KiB
TypeScript

import Head from 'next/head'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { Anchor, Paragraph, XStack, YStack } from '@hanzo/ui'
import Film from '../components/Film'
import { Label } from '../components/kit'
import Speaker from '../components/Speaker'
import ZooLogo from '../components/ZooLogo'
import { reply, type Message } from '../lib/chat'
import { clipFor, OPENERS, RESTING, read, systemPrompt, visible, type Feeling } from '../lib/companion'
import { full, useArrival, useSeed } from '../lib/arrival'
import { hush, say } from '../lib/voice'
/**
* The logged-out door: Blue, full bleed, and one thing to do.
*
* This screen is deliberately not the lab. It is dark because it is underwater
* — the clip IS the page, not an illustration on it — and it holds a single
* question box, because someone arriving for the first time should not have to
* choose between eleven surfaces. The lab behind the sign-in is the light,
* hard-edged system every other Zoo surface uses; the two are different rooms
* and are meant to look it.
*/
type Turn = { role: 'user' | 'assistant'; text: string }
export default function Blue() {
const { embedded, seed, corpus } = useArrival()
const [turns, setTurns] = useState<Turn[]>([])
const [draft, setDraft] = useState('')
const [streaming, setStreaming] = useState('')
const [thinking, setThinking] = useState(false)
const [feeling, setFeeling] = useState<Feeling | null>(null)
const [problem, setProblem] = useState('')
const [voice, setVoice] = useState(false)
const [rest, setRest] = useState(0)
const tail = useRef<HTMLDivElement>(null)
const src = feeling ? clipFor(feeling) : RESTING[rest]
useEffect(() => {
if (turns.length || streaming) tail.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [turns, streaming])
async function ask(question: string) {
if (!question.trim() || thinking) return
const asked: Turn = { role: 'user', text: question.trim() }
const history: Message[] = [
{ role: 'system', content: systemPrompt(corpus) },
...turns.map((t) => ({ role: t.role, content: t.text })),
{ role: 'user', content: asked.text },
]
setTurns((t) => [...t, asked])
setDraft('')
setProblem('')
setThinking(true)
hush()
let buffer = ''
try {
for await (const token of reply(history)) {
buffer += token
setStreaming(visible(buffer))
}
const { text, feeling: felt } = read(buffer)
setTurns((t) => [...t, { role: 'assistant', text }])
if (felt) setFeeling(felt)
if (voice) say(text)
} catch (err) {
setProblem(err instanceof Error ? err.message : String(err))
} finally {
setStreaming('')
setThinking(false)
}
}
useSeed(seed, ask)
const spoken = turns.length > 0 || Boolean(streaming) || Boolean(problem)
// What a reader carries with them when they open the full room.
const last = [...turns].reverse().find((t) => t.role === 'user')?.text ?? ''
return (
<>
<Head>
<title>Blue — Zoo Labs</title>
<meta
name="description"
content="Blue is a beluga whale and a marine scientist at Zoo Labs Foundation. Ask about the ocean, endangered species, or how research actually gets done."
/>
{/* This page paints its own dark ground rather than the site's paper. */}
<meta name="theme-color" content="#06202c" />
</Head>
<div className="deep">
<Film
src={src}
loop={Boolean(feeling)}
onEnded={() => !feeling && setRest((n) => (n + 1) % RESTING.length)}
/>
<div className="deep-veil" />
<div className={spoken ? 'deep-stage talking' : 'deep-stage'}>
{embedded ? (
/* Inside someone else's hero their page already carries a header,
and the one thing worth offering here is the whole room this is a
window into — with the question already asked. */
<XStack items="center" justify="flex-end" px="$4" py="$3">
<Anchor href={full(last)} target="_top" textDecorationLine="none">
<Label color="rgba(255,255,255,0.92)" fontWeight="600">
Open Blue&nbsp;↗
</Label>
</Anchor>
</XStack>
) : (
<XStack items="center" gap="$3" px="$4" py="$3">
<XStack items="center" gap="$2">
<ZooLogo size={22} />
<Label color="rgba(255,255,255,0.92)" fontSize="$3" fontWeight="600">
Blue · Zoo
</Label>
</XStack>
<YStack flex={1} />
<Link href="/research">
<Label color="rgba(255,255,255,0.72)" fontWeight="500">
Research
</Label>
</Link>
<Anchor href="https://papers.zoo.ngo" textDecorationLine="none">
<Label color="rgba(255,255,255,0.72)" fontWeight="500">
Papers
</Label>
</Anchor>
<Anchor
href="https://zoolabs.id?client_id=zoo-app&redirect_uri=https://zoolabs.io"
textDecorationLine="none"
>
<Label color="rgba(255,255,255,0.92)" fontWeight="600">
Sign in
</Label>
</Anchor>
</XStack>
)}
<YStack className="deep-middle" flex={1} justify="center" items="center" px="$4" gap="$4">
{!spoken && (
<YStack items="center" gap="$3" maxW={560} style={{ textAlign: 'center' }}>
<h1>Hi, I&rsquo;m Blue the beluga.</h1>
<Paragraph color="rgba(255,255,255,0.72)">
Ask me about the ocean, endangered species, or how scientists actually find
things out. Watch how I feel as we talk.
</Paragraph>
<XStack flexWrap="wrap" justify="center" gap="$2" mt="$2">
{OPENERS.slice(0, 3).map((opener) => (
<button key={opener} className="deep-chip" onClick={() => ask(opener)}>
{opener}
</button>
))}
</XStack>
</YStack>
)}
{spoken && (
<div className="deep-thread">
{turns.map((turn, i) => (
<div key={i} className={turn.role === 'user' ? 'deep-said deep-mine' : 'deep-said'}>
{turn.text}
</div>
))}
{streaming ? <div className="deep-said">{streaming}</div> : null}
{problem ? (
<div className="deep-said deep-wrong" role="alert">
Blue could not answer — {problem}. Nothing was made up in its place.
</div>
) : null}
<div ref={tail} />
</div>
)}
</YStack>
<YStack className="deep-ask-row" items="center" px="$4" pb="$5" gap="$2">
<form
className="deep-ask"
onSubmit={(e) => {
e.preventDefault()
ask(draft)
}}
>
<input
id="question"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Talk to Blue…"
aria-label="Talk to Blue"
autoComplete="off"
/>
<button
type="button"
onClick={() => {
const on = !voice
setVoice(on)
if (!on) hush()
else if (turns.length) say(turns[turns.length - 1].text)
}}
aria-pressed={voice}
aria-label={voice ? 'Stop reading answers aloud' : 'Read answers aloud'}
className="deep-voice"
>
<Speaker on={voice} size={17} />
</button>
<button type="submit" disabled={thinking || !draft.trim()} aria-label="Ask Blue">
{thinking ? '…' : '↑'}
</button>
</form>
<Label color="rgba(255,255,255,0.55)" fontSize="$1" fontWeight="400">
{thinking ? 'Blue is thinking…' : feeling ? `Blue feels ${feeling}` : 'Open source · 501(c)(3)'}
</Label>
</YStack>
</div>
</div>
</>
)
}