Files
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

304 lines
13 KiB
TypeScript

'use client'
import { useState, useRef, useEffect } from 'react'
import { Check, Copy, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react'
import { useI18n } from '@hanzo/i18n/react'
import { Mark } from '@/components/models/Mark'
import { glass } from '@hanzo/ui/glass'
import { Text, View, XStack } from '@hanzo/ui'
import { Action } from '@hanzo/ui/marketing'
import { SETUP } from './prompts'
/**
* The fold's second action: the setup prompt, on the clipboard, or carried
* straight into the agent the reader already has open.
*
* ONE SENTENCE, TWO ROADS. The left half copies `SETUP` (./prompts) — read
* /skill.md, sign in, pick a starter, boot it — which is the instruction an
* agent needs, not the manual it points at. The menu hands the same sentence
* to an agent by name. The href is `/skill.md`, so with scripting off the link
* still opens the manual itself.
*
* WHAT WENT IN THE LINK AND WHAT WENT ON THE CLIPBOARD IS NOT A PREFERENCE.
* Each scheme below was read out of the app installed on this machine, and two
* of the four cannot carry a prompt at all:
*
* claude-cli://open?q=… Claude Code 2.1.268 parses the URL itself: it
* rejects any host but `open` and reads `q`, `cwd` and `repo`. The prompt
* rides in `q`.
* https://cursor.com/link/prompt?text=… a WEB address, answering 200 with
* "Open in Cursor", which then hands off to the app. Nothing to install for
* the link itself to work, so this one can never dead-click.
* codex://threads/new Codex's handler accepts four hosts — settings,
* skills, automations, threads — maps each to a fixed route and reads no
* query string whatsoever. `codex://new?prompt=…`, which is the shape
* going around, matches no host and does nothing at all.
* antigravity-ide:// Antigravity IDE registers exactly one scheme and
* handles the two forms every VS Code fork does: open a window, open a
* file. There is no prompt path, and inventing one ships a dead link.
*
* So the prompt travels by whichever road exists, and EVERY row puts it on the
* clipboard on its way out. That is not belt and braces: a custom scheme with
* no app behind it fails silently — the browser does nothing, and no event
* says so — and copying first means the reader is holding the prompt either
* way, with no timer racing a window that may never open.
*
* A ROW IS A LINK, and nothing about it is cancelled. The address is then
* readable on hover, copyable from the context menu, and still there with
* scripting off — the same principle the left half of this button already
* follows. The click writes the clipboard and gets out of the browser's way.
*
* MCP IS NOT IN HERE. Wiring the server up and giving an agent a job are
* different acts, and the second is what a fold is for. The install lives at
* docs.hanzo.ai/docs/mcp, which the panel links, where it can carry the whole
* client list instead of one command per row.
*
* THE WORDS ARE THE READER'S LANGUAGE, THE PROMPT IS NOT. Every label is
* translated; `SETUP` is an instruction for a coding agent and goes on the
* clipboard as written. The two halves meet at the end of the copy half and the
* start of the menu's, so on a right-to-left page the corners and the edge they
* give up trade sides (`dir`): each half states one physical side, as `Action`
* does, rather than a logical one that would race it in the cascade.
*/
const q = encodeURIComponent(SETUP)
/**
* Which way the panel opens, and the two numbers that decide it.
*
* `PANEL` is its height — four rows, the sentence under them and the two
* leads, measured at 305. `FLOOR` is the composer, fixed to the bottom of every
* viewport: a panel can clear the window and still open UNDER that bar, which
* is what a phone did with the old estimate, and the last row went behind it.
* So the room asked for is the panel plus the bar, and the panel goes upward
* when a viewport cannot give it that.
*/
const PANEL = 306
const FLOOR = 80
/**
* The four doors, and what each one can actually carry.
*
* `web` marks the address that is not a scheme: it resolves for everybody, so
* it opens in a tab of its own, and it is the only row whose failure mode is a
* page instead of silence.
*/
const AGENTS: Array<{ name: string; org: string; href: string; carries: boolean; web?: boolean }> = [
{ name: 'Claude Code', org: 'claudecode', href: `claude-cli://open?q=${q}`, carries: true },
{ name: 'Codex', org: 'codex', href: 'codex://threads/new', carries: false },
{ name: 'Cursor', org: 'cursor', href: `https://cursor.com/link/prompt?text=${q}`, carries: true, web: true },
{ name: 'Antigravity', org: 'antigravity', href: 'antigravity-ide://?windowId=_blank', carries: false },
]
export default function CopyPrompt() {
const { t, dir } = useI18n()
const rtl = dir === 'rtl'
const [copied, setCopied] = useState(false)
const [copiedLabel, setCopiedLabel] = useState('')
const [menuOpen, setMenuOpen] = useState(false)
const [dropUp, setDropUp] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const toggleMenu = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!menuOpen && menuRef.current) {
const rect = menuRef.current.getBoundingClientRect()
setDropUp(window.innerHeight - rect.bottom < PANEL + FLOOR)
}
setMenuOpen(!menuOpen)
}
const say = (what: string) => {
setCopied(true)
setCopiedLabel(what)
window.setTimeout(() => {
setCopied(false)
setCopiedLabel('')
}, 2000)
}
const copy = (e: React.MouseEvent<HTMLElement>) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
if (!navigator.clipboard?.writeText) return
e.preventDefault()
void navigator.clipboard.writeText(SETUP).then(() => say(t('Copied prompt')), () => {})
}
/**
* The clipboard, on the way out. Nothing is cancelled — the row is a LINK and
* the browser opens it, which is what makes the address readable on hover, in
* a copied link, and with scripting off.
*/
const hand = (ag: (typeof AGENTS)[number]) => {
void navigator.clipboard?.writeText(SETUP).catch(() => {})
setMenuOpen(false)
say(ag.carries ? t('Opening {name}', { name: ag.name }) : t('Copied for {name}', { name: ag.name }))
}
return (
<XStack ref={menuRef as never} position="relative" display="inline-flex" items="stretch">
{/* The agents' marks in their own colors (`[data-hue]` in app/layout.tsx):
Claude Code's orange as one fill, Codex's and Antigravity's app-icon
gradients from these two paints. Cursor's mark is black and white, so
it keeps the ink. */}
<svg width={0} height={0} aria-hidden focusable="false">
<defs>
<linearGradient id="hue-codex" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#4F7CFF" />
<stop offset="1" stopColor="#A06BFF" />
</linearGradient>
<linearGradient id="hue-antigravity" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stopColor="#34A853" />
<stop offset="0.35" stopColor="#FBBC04" />
<stop offset="0.65" stopColor="#EA4335" />
<stop offset="1" stopColor="#4285F4" />
</linearGradient>
</defs>
</svg>
{/* The two halves of one split control: the copy, and the agents behind
it. Each is an `Action` and gives up the corners and the edge where it
meets the other. */}
<Action
href="/skill.md"
onClick={copy}
aria-live="polite"
{...(rtl ? { pr: 18, pl: 14, borderTopLeftRadius: 0, borderBottomLeftRadius: 0 } : { pl: 18, pr: 14, borderTopRightRadius: 0, borderBottomRightRadius: 0 })}
fontFamily="$body"
data-join="start"
>
{copied ? (
<Check size={14} strokeWidth={2} color="var(--pure-white)" aria-hidden />
) : (
<Text display="inline-flex" opacity={0.8} color="inherit">
<Copy size={14} strokeWidth={1.5} aria-hidden />
</Text>
)}
<Text display="inline-flex" justify="center" minW="calc(17ch)" text="center" whiteSpace="nowrap" color="inherit">
{copied ? copiedLabel || t('Copied prompt') : t('Copy setup prompt')}
</Text>
<Text aria-hidden display="inline-flex" items="center" gap={10} marginInlineStart={8} color="var(--muted-foreground)">
{AGENTS.map((ag) => (
<Text key={ag.name} render="span" display="inline-flex" data-hue={ag.org}>
<Mark org={ag.org} size={18} />
</Text>
))}
</Text>
</Action>
<Action
render="button"
type="button"
onClick={toggleMenu}
/* `true`, not `menu`: what opens is a short list of LINKS, and naming
it a menu promises arrow-key semantics that nothing here implements.
Tab walks it, which is what a group of links does. */
aria-haspopup="true"
aria-expanded={menuOpen}
aria-label={t('Open in an agent')}
px={10}
{...(rtl ? { borderTopRightRadius: 0, borderBottomRightRadius: 0, borderRightWidth: 0 } : { borderTopLeftRadius: 0, borderBottomLeftRadius: 0, borderLeftWidth: 0 })}
data-join="end"
boxShadow="inset 1px 1px 0 0 var(--white-14), 0 4px 16px -2px color-mix(in srgb, var(--pure-black) 30%, transparent)"
>
{menuOpen ? <ChevronUp size={15} aria-hidden /> : <ChevronDown size={15} aria-hidden />}
</Action>
{menuOpen ? (
/* The surface is @hanzo/ui's floating material, `glass(2)`: a menu
anchored to the control that opened it is rung 2 of the ladder. It
states both themes, so no fill is written here — an inline one once
outranked the material and left a dark slab on the light theme with
dark type on it. It rises in, and does not move for a reader who
asked for stillness. */
<View
data-slot="agents"
data-motion="rise"
className={glass(2).className}
position="absolute"
{...(dropUp ? { b: 'calc(100% + 8px)' } : { t: 'calc(100% + 8px)' })}
l="50%"
x="-50%"
z="var(--z-dropdown)"
minW={300}
rounded={14}
p={6}
gap={2}
>
{AGENTS.map((ag) => (
<Text
key={ag.name}
render={<a href={ag.href} target={ag.web ? '_blank' : undefined} rel={ag.web ? 'noreferrer' : undefined} onClick={() => hand(ag)} />}
display="flex"
items="center"
gap={10}
py={9}
px={12}
rounded={9}
bg="transparent"
fontFamily="$body"
fontSize="$2"
fontWeight="500"
color="$ink"
// Stated, not inherited: the fold centres its own column on a
// phone, and a menu row that takes that lands each label at a
// different x. The start of the line, which is the right on a
// right-to-left page, and the hover leans along it.
text="start"
cursor="pointer"
transition="quickest"
hoverStyle={{ bg: 'var(--surface-2)', x: rtl ? -2 : 2 }}
>
<Text render="span" display="inline-flex" data-hue={ag.org}>
<Mark org={ag.org} size={15} />
</Text>
<Text flex={1} color="inherit">
{t('Open in {name}', { name: ag.name })}
</Text>
</Text>
))}
<View height={1} bg="$edge" mt={2} mb={4} />
<Lead href="/skill.md">{t('View skill.md')}</Lead>
<Lead href="https://docs.hanzo.ai/docs/mcp" external>
{t('Set up MCP')}
</Lead>
</View>
) : null}
</XStack>
)
}
/** The two ways out of the panel, which are addresses rather than actions. */
function Lead({ href, external, children }: { href: string; external?: boolean; children: React.ReactNode }) {
return (
<Text
render={<a href={href} {...(external ? { target: '_blank', rel: 'noreferrer' } : null)} />}
display="flex"
items="center"
justify="space-between"
py={9}
px={12}
rounded={9}
fontFamily="$body"
fontSize="$2"
color="var(--muted-foreground)"
transition="quickest"
hoverStyle={{ color: '$ink', bg: 'var(--surface-2)' }}
>
<Text color="inherit">{children}</Text>
<ExternalLink size={12} aria-hidden data-i18n-mirror />
</Text>
)
}