Files
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

224 lines
11 KiB
TypeScript

'use client'
/**
* The row at the foot of the first screen: every agent this already works with, wearing its own
* mark, and each one a door into the page that sets it up.
*
* IT WAS EIGHT PLAIN WORDS. "Claude Code · Codex · Gemini · Qwen" as bare text
* is the same failure the rest of this page just fixed one section at a time: a
* list of names a reader has to parse instead of a row of marks they recognise
* at a glance. The marks are the point — a stranger scanning this band should
* see the logo of the tool already on their machine.
*
* `ProviderMark` is the ONE mark table (@hanzo/app, fetched when a mark is first drawn: components/models/Mark.tsx),
* bodies copied unmodified from hanzoai/icons. Never draw a logo here: a lab
* with no mark in that table falls back to a monogram at the SAME footprint, so
* the row does not reflow, and that fallback is the documented last resort
* rather than an invitation to invent one.
*
* THE NAMES ARE LINKS AGAIN, and the reason they stopped being links is worth
* keeping: a predecessor pointed all nine at `docs.hanzo.ai/agents/<name>` and
* seven answered 404, so the whole row went back to plain text. The addresses
* below are not that pattern. Each was requested and answered 200, and each was
* then READ: `/docs/coding` is the page that names Claude Code and Codex,
* `/docs/apps/ides` is the one that names Cursor and Antigravity, `/docs/dev`
* is Hanzo Dev's own. Four rows — Gemini, Qwen, Hermes, OpenClaw — have no page
* of their own anywhere in docs' 2,882-URL sitemap, and they go to `/docs/mcp`,
* which is where an MCP client of any name is told what to do. That is a real
* answer rather than a near miss, and it is marked below so the next person
* does not have to measure it again.
*
* THE BUTTON AT THE END HANDS AN AGENT ITS INSTRUCTIONS. It copies `SETUP`
* (./prompts), the same sentence the fold's Copy setup prompt and every
* "Open in" row carry: read /skill.md, sign in, pick a starter, boot it. A
* reader who recognises their agent in this row pastes one line into it.
*/
import { useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { Text, View, XStack, YStack } from '@hanzo/ui'
import { Mark } from '@/components/models/Mark'
import { SETUP } from './prompts'
/** The terminal's own face, for the two pieces of mono on the band: no
* ligatures and none of the body's stylistic sets, as the transcript sets it. */
const FACE = { fontFamily: '$mono', '$platform-web': { fontVariantLigatures: 'none', fontFeatureSettings: 'normal' } } as const
/** The window onto the track: names dissolve at either edge rather than being cut. */
const FADE = 'linear-gradient(90deg, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%)'
/** What the button says at rest; `Copied` for two seconds after. */
const TAKE = 'Copy setup prompt'
/** One lap: the track travels one third of itself, one pass, so the row never snaps back. */
const LAP: Keyframe[] = [{ transform: 'translateX(0)' }, { transform: 'translateX(calc(-100% / 3))' }]
const LAP_MS = 42_000
/**
* The track's motion, run on the element by the Web Animations API.
*
* It holds while the pointer is over the window or focus is inside it, because
* every name is a click target and a sliding link is a game of catch, and it
* does not run at all for a reader who asked for no motion. A ref callback, so
* it starts and stops with the element.
*/
function lap(track: HTMLElement | null) {
const view = track?.parentElement
if (!track || !view || typeof track.animate !== 'function') return
const still = window.matchMedia('(prefers-reduced-motion: reduce)')
let run: Animation | null = null
const hold = () => {
if (!run) return
if (view.matches(':hover, :focus-within')) run.pause()
else run.play()
}
const start = () => {
run?.cancel()
run = still.matches ? null : track.animate(LAP, { duration: LAP_MS, iterations: Infinity, easing: 'linear' })
hold()
}
const watch = ['pointerenter', 'pointerleave', 'focusin', 'focusout'] as const
start()
still.addEventListener('change', start)
for (const e of watch) view.addEventListener(e, hold)
return () => {
still.removeEventListener('change', start)
for (const e of watch) view.removeEventListener(e, hold)
run?.cancel()
}
}
/** Declared once, so a render of the strip (the button's `Copied`) does not restart the lap. */
const track = (el: unknown) => lap(el instanceof HTMLElement ? el : null)
/**
* Each agent, its mark, and the page that sets it up.
*
* `mcp` marks the four with no page of their own. They are MCP clients, so
* `/docs/mcp` is their setup page in the same sense it is Cursor's — it just is
* not a page about THEM, and saying which is which here is cheaper than
* measuring the sitemap again.
*/
const AGENTS: Array<{ name: string; org: string; href: string; mcp?: true }> = [
{ name: 'Claude Code', org: 'claudecode', href: 'https://docs.hanzo.ai/docs/coding' },
{ name: 'Codex', org: 'codex', href: 'https://docs.hanzo.ai/docs/coding' },
{ name: 'Gemini', org: 'google', href: 'https://docs.hanzo.ai/docs/mcp', mcp: true },
{ name: 'Qwen', org: 'qwen', href: 'https://docs.hanzo.ai/docs/mcp', mcp: true },
{ name: 'Cursor', org: 'cursor', href: 'https://docs.hanzo.ai/docs/apps/ides' },
{ name: 'Antigravity', org: 'antigravity', href: 'https://docs.hanzo.ai/docs/apps/ides' },
{ name: 'Hermes', org: 'hermesagent', href: 'https://docs.hanzo.ai/docs/mcp', mcp: true },
{ name: 'OpenClaw', org: 'openclaw', href: 'https://docs.hanzo.ai/docs/mcp', mcp: true },
{ name: 'Hanzo Dev', org: 'hanzo', href: 'https://docs.hanzo.ai/docs/dev' },
{ name: 'MCP', org: 'mcp', href: 'https://docs.hanzo.ai/docs/mcp' },
{ name: 'Anthropic SDK', org: 'anthropic', href: 'https://docs.hanzo.ai/docs/guides/integrations/claude-agent-sdk' },
{ name: 'OpenAI SDK', org: 'openai', href: 'https://docs.hanzo.ai/docs/guides/integrations/openai-agents' },
{ name: 'Universal SDKs', org: 'enso', href: 'https://docs.hanzo.ai/docs/sdks' },
]
export default function AgentStrip() {
const [took, setTook] = useState(false)
const take = () => {
void navigator.clipboard?.writeText(SETUP).catch(() => {})
setTook(true)
window.setTimeout(() => setTook(false), 2000)
}
return (
<YStack data-slot="agent-strip" overflow="hidden" py={14} borderTopWidth={1} borderBottomWidth={1} borderColor="$edge">
{/* The row WRAPS, so the two fixed ends never squeeze the track down to
one and a half names. The track's window asks for the rest of the
line, and on a phone takes the whole one below them. */}
<XStack flexWrap="wrap" items="center" gap={16} px={16}>
<Text {...FACE} shrink={0} pr={4} fontSize="$1" color="var(--text-tertiary)" whiteSpace="nowrap">
Drop into any agent
</Text>
{/* THREE identical passes, and the track translates one third of
itself per lap. What leaves on the left is what the next pass
already put on the right, so the row never snaps back and never
shows a gap: a pass is about 1,500px, and three of them keep the
window full while one scrolls out, up to a 3,000px screen. Two
passes left the tail of the lap empty on any screen wider than one
pass. The copies are aria-hidden and out of the tab order: the same
names read three times is noise to a reader who never sees the
animation that makes them look continuous.
It is `overflow-x: auto` and never `hidden`, so the row is
scrollable by hand at any width. That matters most where the
animation is off — under prefers-reduced-motion nothing moves, and
a row that could only be read by waiting would be unreadable. The
track (`lap`) also pauses on hover and on focus-within, because
every name in it is a click target and a sliding link is a game of
catch.
Both ends FADE, because a hard cut mid-word reads as a typo rather
than as motion. On a phone the window takes the whole line under
the label and the setup pill: between them a 390px row leaves a
name and a half, which conveys nothing. */}
{/* @ts-expect-error gui applies `order` on the web; its types do not list it. */}
<View
data-slot="agent-window"
flex={1}
minW={0}
overflowX="auto"
maskImage={FADE}
$platform-web={{ scrollbarWidth: 'none' }}
$max-sm={{ order: 1, flexBasis: '100%' }}
>
<XStack data-slot="agent-track" ref={track} width="max-content">
{[0, 1, 2].map((pass) => (
<XStack key={pass} aria-hidden={pass > 0 ? true : undefined}>
{AGENTS.map((a) => (
<XStack
key={`${pass}-${a.name}`}
render={<a href={a.href} target="_blank" rel="noreferrer" tabIndex={pass > 0 ? -1 : undefined} />}
display="inline-flex"
items="center"
gap={8}
shrink={0}
pr={28}
$touchable={{ minH: 44 }}
>
{/* House ink. This row is a marketing band and every mark
has its name beside it, so the brand hue identifies
nothing here — it just puts a dozen palettes on a
monochrome page. The catalog and the charts still get
the real colours, where the colour is the data. */}
<Mark org={a.org} size={18} ink />
<Text fontSize="$2" color="var(--text-secondary)" whiteSpace="nowrap">
{a.name}
</Text>
</XStack>
))}
</XStack>
))}
</XStack>
</View>
<XStack
render={<button type="button" onClick={take} aria-live="polite" title={SETUP} />}
display="inline-flex"
items="center"
gap={8}
shrink={0}
py={5}
px={10}
rounded={8}
borderWidth={1}
borderColor="$edge"
bg="transparent"
cursor="pointer"
$touchable={{ minH: 44 }}
>
{took ? <Check size={12} strokeWidth={2} color="var(--text-secondary)" aria-hidden /> : <Copy size={12} strokeWidth={1.5} color="var(--text-secondary)" aria-hidden />}
{/* The label swaps for a shorter word, and the width is held so the
band does not shrug every time somebody clicks it. */}
<Text {...FACE} display="inline-flex" minW={`calc(${TAKE.length}ch)`} fontSize="$1" color="var(--text-secondary)">
{took ? 'Copied' : TAKE}
</Text>
</XStack>
</XStack>
</YStack>
)
}