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.
304 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|