shell: every string it draws goes through @hanzo/i18n

The shell renders on every Hanzo property, so a word it draws as bare
English stays English on every page of every locale, whatever the host
translated around it. Each visible string now passes `t()` from
@hanzo/i18n/react: the palettes, both headers, the menus, the footer,
the frame, the account menus, the meter, notifications, the visitor
chat and the launcher. Copy written as data (the registry, the
launcher's apps, the account rows) is marked with `phrase()` where it
is written and drawn through `t()`. A product's name standing alone is
not marked, and host data (palette commands, frame sections, names,
orgs) is drawn as given.

The palettes translate their rows before ranking them, so a reader
searches the words they see. A sentence with a value or a key cap in it
is one message with a placeholder; the meter's span is a sentence per
span. The send arrow mirrors under RTL.

@hanzo/i18n is a required peer (>=0.4.0) so the host's one provider
reaches the shell. With no provider `t` answers the English source: 32
server renders of the components are byte-identical before and after.
The one markup change is the open launcher's "⌘K to switch", now one
sentence around its key cap. i18n.test.mjs parses the source with oxc
and fails on copy that reaches the screen without t() or phrase().

Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
This commit is contained in:
2026-10-22 15:44:31 -07:00
parent af461eb5e4
commit ce42338b5a
31 changed files with 1105 additions and 569 deletions
+15
View File
@@ -128,6 +128,21 @@ Urdu. Flex rows and grid columns follow `dir` on their own; offsets do not.
pick `-end` for RTL yourself (Menu did, and flipped it back to the left). Side
placements (`left`/`right`) are physical and do need the swap.
## The shell's copy is @hanzo/i18n's
Every string `@hanzogui/shell` draws passes `t()` from `@hanzo/i18n/react`
(`useI18n`), a required peer so the host's one `I18nProvider` reaches it; with
no provider `t` answers the English source. Copy written as data — the
registry, the launcher's apps, a row table — is `phrase()`-marked where it is
written and drawn through `t()`; a helper that cannot call a hook takes `t`
(`Say`) or hands back a marked source (`askLabel` returns the sentence and its
value for `t(...askLabel(question))`). A product's name standing alone (Hanzo
Chat, Enso, SDKs) is not marked. Host data is drawn as given: palette
`commands`, Frame `sections`, names, orgs, `find.label`. The palettes search
what they draw, so their rows are translated before `search` ranks them.
`i18n.test.mjs` parses the source with oxc and fails on copy that reaches the
screen without either.
## `tracks` and `Wrap`: the stack sizes its children
`tracks` is a View prop (core, `pkgs/core/web/src/helpers/tracks.ts`, typed on
+5
View File
@@ -2988,9 +2988,12 @@
"name": "@hanzogui/shell",
"version": "8.4.0",
"devDependencies": {
"@hanzo/i18n": "0.4.0",
"@hanzogui/build": "workspace:*",
"oxc-parser": "0.147.0",
},
"peerDependencies": {
"@hanzo/i18n": ">=0.4.0",
"react": ">=19.2.8",
"react-dom": ">=19.2.8",
},
@@ -3896,6 +3899,8 @@
"@hanzo/gui": ["@hanzo/gui@workspace:pkgs/ui/hanzogui"],
"@hanzo/i18n": ["@hanzo/[email protected]", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-NMG+UTRvH3Gef+W8cSFrXpOLrQbe55lDoUbnUcxOt5o/KrgJ9mpo/nMzxm6GlZrDirSEiB5ky/+ZRtMuBVlnfw=="],
"@hanzo/logo": ["@hanzo/[email protected]", "", { "peerDependencies": { "react": ">=16.8.0" }, "optionalPeers": ["react"] }, "sha512-acPRsdTD/pfsDLUias989cDZCCg4Q9rCCrlOWVqlAzcN7xyoi3Vl4DjM7Z+feOL5SDPxWY1M0hJn9B1xqGjK4g=="],
"@hanzo/observe": ["@hanzo/[email protected]", "", { "dependencies": { "@hanzo/event": "0.3.39" }, "peerDependencies": { "react": ">=19.2.8" }, "optionalPeers": ["react"] }, "sha512-PsCE+qaZdmXZVtkCJXFoDbTr5jJZuxctAEPOVk8LyvnRxfjPUVGW03Phu6N2OWyLDwyq0g4voySkO2h3tQzmYA=="],
+9
View File
@@ -288,6 +288,15 @@ it. Pass `apps` to any of the three to override.
- `HANZO_APPS`, `ORG_DOMAINS` — the app registry and the per-org domain map
- `HanzoApp`, `HanzoUser`, `HanzoOrg`, `OrgDomains` — types
## Language
Every word the shell draws goes through `@hanzo/i18n`: mount its
`I18nProvider` once and the menus, the palette, the frame and the account
controls read in the reader's language. Without a provider they read in
English. What a host passes in — a palette command, a section, a person's or an
org's name — is drawn as given.
## Peer dependencies
- `react`, `react-dom`
- `@hanzo/i18n` (>= 0.4.0) — one provider context, the host's
+6 -2
View File
@@ -271,8 +271,12 @@ test('privilege is the server answer, never a client role check', () => {
test('a running masquerade is unmistakable, and has a way out', () => {
const src = read('Masquerade.tsx')
assert.match(src, /background: ACCENT/, 'the loudest fill in the chrome')
assert.match(src, /Acting as \{org\.name\}/, 'it names the org')
assert.match(src, /aria-label=\{`Stop acting as \$\{org\.name\}`\}/, 'and it exits')
assert.match(src, /t\('Acting as \{org\}', \{ org: org\.name \}\)/, 'it names the org')
assert.match(
src,
/aria-label=\{t\('Stop acting as \{org\}', \{ org: org\.name \}\)\}/,
'and it exits'
)
// Mounting the switcher is what puts the sign in the bar — never a copy.
const switcher = read('UserOrgDropdown.tsx')
assert.match(switcher, /<Masquerade org=\{masquerade\}/, 'the switcher carries it')
+209
View File
@@ -0,0 +1,209 @@
/**
* Every word the shell draws can be read in the reader's language.
*
* The shell renders on every Hanzo property, so a sentence it draws as bare
* English is English on every page of every locale, whatever the host
* translated around it. Copy reaches the screen through @hanzo/i18n's `t()`;
* copy written as data (the registry, a row table) is marked with `phrase()`
* where it is written and drawn through `t()` where it renders.
*
* This reads the source the way the extractor does and fails on copy that
* skips both: text between tags, a string or template in an attribute a reader
* sees or hears, a string in a JSX expression outside any call, and a data
* label left unmarked. A product's name standing alone is not copy.
*
* node --test i18n.test.mjs
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readdirSync, readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseSync } from 'oxc-parser'
const SRC = join(dirname(fileURLToPath(import.meta.url)), 'src')
const files = readdirSync(SRC).filter((f) => /\.tsx?$/.test(f))
/** Attributes whose value a reader sees or hears. */
const SAID = new Set([
'aria-label',
'title',
'placeholder',
'alt',
'label',
'hint',
'description',
'caption',
'greeting',
])
/** Data fields that hold copy. */
const COPY = new Set(['label', 'hint', 'tagline', 'title', 'heading', 'description'])
/** Read the same in every language: names, and what a keyboard prints. */
const SAME = new Set([
// a key cap
'ESC',
'⌘K',
// the company and its products, standing alone
'Hanzo',
'Hanzo Chat',
'Hanzo App',
'Hanzo Base',
'Hanzo Dev',
'Hanzo IDE',
'Hanzo CLI',
'Hanzo Platform',
'Hanzo Bot',
'Hanzo Studio',
'Enso',
'Base',
'Chat',
'App',
'Team',
'Bot',
'Studio',
'Cloud',
'Dev',
'World',
'Console',
'GitHub',
// an initialism or a technology's own name
'AI',
'API',
'CLI',
'IDE',
'MCP',
'SDKs',
'GPUs',
'SQL',
'KV',
'DocDB',
'VPC',
'DNS',
'CDN',
'IAM',
'Authz',
'KMS',
'HSM',
'Web3',
// a host name
'hanzo.ai →',
])
const worded = (s) => /\p{L}/u.test(s) && !SAME.has(s.trim())
/** Calls each node with its parent chain, root first. */
function walk(node, visit, chain = []) {
if (Array.isArray(node)) {
for (const n of node) walk(n, visit, chain)
return
}
if (!node || typeof node.type !== 'string') return
visit(node, chain)
const next = [...chain, node]
for (const key in node) {
if (key === 'type') continue
const v = node[key]
if (v && typeof v === 'object') walk(v, visit, next)
}
}
/**
* What an expression can evaluate to and draw: its strings outside any call.
* Nested markup is read on its own visit, and a style object, a callback or a
* comparison is never drawn.
*/
const ASIDE = new Set([
'CallExpression',
'JSXElement',
'JSXFragment',
'ObjectExpression',
'ArrowFunctionExpression',
'FunctionExpression',
'BinaryExpression',
'MemberExpression',
])
function bare(expression) {
const found = []
walk(expression, (n, chain) => {
if (chain.some((c) => ASIDE.has(c.type))) return
if (n.type === 'Literal' && typeof n.value === 'string' && worded(n.value))
found.push(n.value)
if (n.type === 'TemplateLiteral') {
const words = n.quasis.map((q) => q.value.cooked).join('')
if (worded(words)) found.push(words)
}
})
return found
}
function offences(file) {
const src = readFileSync(join(SRC, file), 'utf8')
const { program, errors } = parseSync(file, src)
assert.equal(errors.length, 0, `${file} parses`)
const line = (n) => src.slice(0, n.start).split('\n').length
const out = []
walk(program, (n, chain) => {
const parent = chain[chain.length - 1]
if (n.type === 'JSXText' && worded(n.value)) {
out.push(`${file}:${line(n)} text "${n.value.trim()}"`)
}
if (n.type === 'JSXAttribute' && SAID.has(n.name?.name) && n.value) {
const words =
n.value.type === 'Literal'
? worded(String(n.value.value))
? [n.value.value]
: []
: bare(n.value)
for (const w of words) out.push(`${file}:${line(n)} ${n.name.name}="${w}"`)
}
if (
n.type === 'JSXExpressionContainer' &&
parent?.type !== 'JSXAttribute' &&
chain.some((c) => c.type === 'JSXElement' || c.type === 'JSXFragment')
) {
for (const w of bare(n.expression)) out.push(`${file}:${line(n)} {"${w}"}`)
}
if (
n.type === 'Property' &&
!n.computed &&
COPY.has(n.key?.name) &&
n.value?.type === 'Literal' &&
typeof n.value.value === 'string' &&
worded(n.value.value)
) {
out.push(`${file}:${line(n)} ${n.key.name}: '${n.value.value}' (unmarked)`)
}
})
return out
}
test('no copy reaches the screen without @hanzo/i18n', () => {
assert.deepEqual(files.flatMap(offences), [])
})
test('the registry marks its copy where it is written', () => {
// The data the menus, the footer and the palette draw. A tagline left
// unmarked is one the extractor never sees, so no catalog ever holds it.
for (const file of ['hanzo-registry.ts', 'hanzo-apps.tsx']) {
const src = readFileSync(join(SRC, file), 'utf8')
assert.match(
src,
/import \{ phrase \} from '@hanzo\/i18n'/,
`${file} marks with phrase`
)
}
})
test('the frame and its pieces draw through the reader’s t', () => {
// A helper that cannot call a hook takes `t` or hands back a marked source;
// it never builds a sentence by concatenation.
const frame = readFileSync(join(SRC, 'commandPalette.tsx'), 'utf8')
assert.match(frame, /\[phrase\('Ask AI: \{question\}'\), \{ question \}\]/)
for (const file of ['HanzoCommandPalette.tsx', 'OrgCommandPalette.tsx']) {
const src = readFileSync(join(SRC, file), 'utf8')
assert.match(src, /t\(\.\.\.askLabel\(question\)\)/, `${file} translates the ask row`)
}
})
+4 -1
View File
@@ -60,9 +60,12 @@
"clean:build": "hanzogui-build clean:build"
},
"devDependencies": {
"@hanzogui/build": "workspace:*"
"@hanzo/i18n": "0.4.0",
"@hanzogui/build": "workspace:*",
"oxc-parser": "0.147.0"
},
"peerDependencies": {
"@hanzo/i18n": ">=0.4.0",
"react": ">=19.2.8",
"react-dom": ">=19.2.8"
}
+21 -12
View File
@@ -18,6 +18,8 @@
* Inside a <Frame> the card carries the sidebar's collapse control beside it.
*/
import React, { useEffect, useRef, useState, type ReactNode } from 'react'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { useFrame } from './Frame.tsx'
import { MARKS, type GlyphName } from './glyph.tsx'
import { useFrameStyles } from './shellStyles.ts'
@@ -56,12 +58,12 @@ interface Row {
export function rows(props: AccountProps): Row[] {
const all: (Row | null)[] = [
props.onProfile
? { id: 'profile', label: 'Profile', glyph: 'user', go: props.onProfile }
? { id: 'profile', label: phrase('Profile'), glyph: 'user', go: props.onProfile }
: null,
props.onNotifications
? {
id: 'notifications',
label: 'Notifications',
label: phrase('Notifications'),
glyph: 'bell',
go: props.onNotifications,
}
@@ -69,20 +71,26 @@ export function rows(props: AccountProps): Row[] {
props.onAppearance
? {
id: 'appearance',
label: 'Appearance',
label: phrase('Appearance'),
glyph: 'palette',
go: props.onAppearance,
}
: null,
props.onSecurity
? { id: 'security', label: 'Security', glyph: 'shield', go: props.onSecurity }
? {
id: 'security',
label: phrase('Security'),
glyph: 'shield',
go: props.onSecurity,
}
: null,
{ id: 'signout', label: 'Sign out', glyph: 'exit', go: props.onSignOut },
{ id: 'signout', label: phrase('Sign out'), glyph: 'exit', go: props.onSignOut },
]
return all.filter((r): r is Row => r !== null)
}
function Item({ row, count, onDone }: { row: Row; count?: number; onDone: () => void }) {
const { t } = useI18n()
const Mark = MARKS[row.glyph]
return (
<button
@@ -96,10 +104,10 @@ function Item({ row, count, onDone }: { row: Row; count?: number; onDone: () =>
style={{ padding: '8px 12px', color: FRAME.ink }}
>
<Mark size={15} />
<span>{row.label}</span>
<span>{t(row.label)}</span>
{count ? (
<span
aria-label={`${count} unread`}
aria-label={t('{n} unread', { n: count })}
style={{
flex: 'none',
minWidth: 18,
@@ -120,6 +128,7 @@ function Item({ row, count, onDone }: { row: Row; count?: number; onDone: () =>
export function Account(props: AccountProps) {
useFrameStyles()
const { t } = useI18n()
const { name, email, picture, face, unread } = props
const frame = useFrame()
const [open, setOpen] = useState(false)
@@ -144,7 +153,7 @@ export function Account(props: AccountProps) {
}
}, [open])
const called = name?.trim() || email || 'You'
const called = name?.trim() || email || t('You')
const list = rows(props)
const last = list.length - 1
@@ -158,7 +167,7 @@ export function Account(props: AccountProps) {
<div
ref={menu}
role="menu"
aria-label="Account"
aria-label={t('Account')}
onKeyDown={(e) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
e.preventDefault()
@@ -209,7 +218,7 @@ export function Account(props: AccountProps) {
<button
type="button"
data-frame-row=""
aria-label="Account"
aria-label={t('Account')}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
@@ -259,8 +268,8 @@ export function Account(props: AccountProps) {
<button
type="button"
data-frame-icon=""
aria-label="Collapse sidebar"
title="Collapse sidebar"
aria-label={t('Collapse sidebar')}
title={t('Collapse sidebar')}
onClick={frame.toggle}
style={{ minWidth: 32, minHeight: 32, marginInlineEnd: 6 }}
>
+33 -19
View File
@@ -18,11 +18,13 @@
* deployments pass a short-lived token via `authToken` or, better, own the call
* entirely with `onSubmit`.
*
* Self-contained: inline styles + theme.ts tokens, React the only runtime dep.
* Self-contained: inline styles + theme.ts tokens, React and @hanzo/i18n the only
* runtime deps.
* Accessible: real buttons, Esc closes and restores focus, the composer
* autofocuses on open, the message log is an aria-live region.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@hanzo/i18n/react'
import {
ACCENT,
ACCENT_SOFT,
@@ -75,7 +77,7 @@ export interface AskHanzoProps {
* single action as the header's — see <HanzoIdentity>.
*/
auth?: HanzoAuth
/** Composer placeholder. */
/** Composer placeholder. Defaults to "Ask Hanzo anything…". */
placeholder?: string
/** Fully override the transport — return the assistant reply for a turn. */
onSubmit?: (message: string, history: AskHanzoMessage[]) => Promise<string>
@@ -86,7 +88,7 @@ export interface AskHanzoProps {
corner?: boolean
/** Custom trigger. Defaults to the corner launcher, or an "Ask Hanzo" pill. */
trigger?: (open: () => void) => React.ReactNode
/** Greeting shown before the first turn. */
/** Greeting shown before the first turn. Defaults to the product's own. */
greeting?: string
className?: string
}
@@ -109,15 +111,16 @@ export function AskHanzo({
endpoint = DEFAULT_ENDPOINT,
model = 'enso-free',
authToken,
placeholder = 'Ask Hanzo anything…',
placeholder,
onSubmit,
auth,
corner,
trigger,
greeting = 'Ask about products, models, APIs, or how to get started.',
greeting,
className,
}: AskHanzoProps) {
useShellStyles()
const { t } = useI18n()
const [open, setOpen] = useState(false)
const [messages, setMessages] = useState<AskHanzoMessage[]>([])
const [draft, setDraft] = useState('')
@@ -206,16 +209,16 @@ export function AskHanzo({
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const data = await res.json()
reply = data?.choices?.[0]?.message?.content ?? '(no response)'
reply = data?.choices?.[0]?.message?.content ?? t('(no response)')
}
setMessages((m) => [...m, { role: 'assistant', content: reply }])
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
setError(err instanceof Error ? err.message : t('Something went wrong.'))
} finally {
setBusy(false)
requestAnimationFrame(() => inputRef.current?.focus())
}
}, [draft, busy, messages, onSubmit, endpoint, authToken, model])
}, [draft, busy, messages, onSubmit, endpoint, authToken, model, t])
return (
<span className={className}>
@@ -231,7 +234,7 @@ export function AskHanzo({
{...controlHover()}
>
<Sparkle />
Ask Hanzo
{t('Ask Hanzo')}
</button>
)}
@@ -253,7 +256,7 @@ export function AskHanzo({
ref={panelRef}
role="dialog"
aria-modal={corner ? undefined : 'true'}
aria-label="Ask Hanzo"
aria-label={t('Ask Hanzo')}
data-hanzo-shell=""
data-hanzo-edge={corner ? 'corner' : 'drawer'}
style={{
@@ -307,12 +310,14 @@ export function AskHanzo({
}}
>
<Sparkle />
<strong style={{ fontSize: FS.base, fontWeight: 700 }}>Ask Hanzo</strong>
<strong style={{ fontSize: FS.base, fontWeight: 700 }}>
{t('Ask Hanzo')}
</strong>
<div style={{ flex: 1 }} />
<button
type="button"
onClick={closePanel}
aria-label="Close"
aria-label={t('Close')}
style={{
display: 'inline-flex',
alignItems: 'center',
@@ -352,14 +357,19 @@ export function AskHanzo({
}}
>
{canChat
? greeting
: 'Sign in and Enso answers questions about products, models, APIs, and how to get started.'}
? (greeting ??
t('Ask about products, models, APIs, or how to get started.'))
: t(
'Sign in and Enso answers questions about products, models, APIs, and how to get started.'
)}
</p>
) : (
messages.map((m, i) => <Bubble key={i} message={m} />)
)}
{busy ? (
<div style={{ fontSize: FS.sm, color: CHROME.fgDim }}>Thinking…</div>
<div style={{ fontSize: FS.sm, color: CHROME.fgDim }}>
{t('Thinking…')}
</div>
) : null}
{error ? (
<div
@@ -409,7 +419,7 @@ export function AskHanzo({
}
}}
rows={1}
placeholder={placeholder}
placeholder={placeholder ?? t('Ask Hanzo anything…')}
style={{
flex: 1,
resize: 'none',
@@ -431,7 +441,7 @@ export function AskHanzo({
type="button"
onClick={() => void send()}
disabled={!draft.trim() || busy}
aria-label="Send"
aria-label={t('Send')}
style={{
display: 'inline-flex',
alignItems: 'center',
@@ -473,7 +483,9 @@ export function AskHanzo({
onMouseEnter={(e) => (e.currentTarget.style.opacity = '0.85')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')}
>
{auth?.label ?? 'Sign in'} to chat
{auth?.label
? t('{action} to chat', { action: auth.label })
: t('Sign in to chat')}
</a>
</div>
)}
@@ -495,12 +507,13 @@ export function AskHanzo({
* makes the visitor hunt for the way out.
*/
function CornerLauncher({ open, onClick }: { open: boolean; onClick: () => void }) {
const { t } = useI18n()
const Enso = MARKS.circle
return (
<button
type="button"
onClick={onClick}
aria-label={open ? 'Close Enso' : 'Ask Enso'}
aria-label={open ? t('Close Enso') : t('Ask Enso')}
aria-expanded={open}
style={{
position: 'fixed',
@@ -579,6 +592,7 @@ function SendGlyph() {
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
data-i18n-mirror=""
>
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
+28 -21
View File
@@ -23,8 +23,8 @@
* a prop because moving between rooms is the one thing only a host can do, so
* the same frame mounts under Next, under Vite, or under a test.
*
* Self-contained like the rest of this package: React the only runtime
* dependency, inline styles and theme.ts tokens, and the layout rules inline
* Self-contained like the rest of this package: React and @hanzo/i18n the only
* runtime dependencies, inline styles and theme.ts tokens, and the layout rules inline
* styles cannot state in shellStyles.ts.
*/
import React, {
@@ -39,6 +39,7 @@ import React, {
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { useI18n } from '@hanzo/i18n/react'
import { MARKS } from './glyph.tsx'
import { HanzoMark } from './mark.tsx'
import { useFrameStyles } from './shellStyles.ts'
@@ -344,8 +345,9 @@ function Rail({
/** A phone's tab bar moves the reader while the sheet may be out: put it away. */
onPick: () => void
}) {
const { t } = useI18n()
return (
<nav data-slot="rail" aria-label="Places">
<nav data-slot="rail" aria-label={t('Places')}>
{strip ? <div data-slot="strip">{strip}</div> : null}
<div data-slot="places">
{/* A phone shows four sections and this; a laptop never draws it. */}
@@ -354,12 +356,12 @@ function Rail({
type="button"
data-slot="more"
data-frame-tile=""
aria-label="More places"
title="More"
aria-label={t('More places')}
title={t('More')}
onClick={more}
>
<MARKS.search size={18} />
<span>More</span>
<span>{t('More')}</span>
</button>
) : null}
{sections.map((section) => (
@@ -378,12 +380,12 @@ function Rail({
<button
type="button"
data-frame-tile=""
aria-label="Settings"
title="Settings"
aria-label={t('Settings')}
title={t('Settings')}
onClick={settings}
>
<MARKS.gear size={18} />
<span>Settings</span>
<span>{t('Settings')}</span>
</button>
</div>
) : null}
@@ -420,6 +422,7 @@ function Control({
/** The search, as a field-shaped button: the palette is the host's. */
function Search({ find }: { find: Find }) {
const { t } = useI18n()
return (
<button
type="button"
@@ -445,7 +448,7 @@ function Search({ find }: { find: Find }) {
<span
style={{ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}
>
Search {find.label}
{t('Search {label}', { label: find.label })}
</span>
<span
aria-hidden="true"
@@ -491,6 +494,7 @@ export function Frame({
children,
}: FrameProps) {
useFrameStyles()
const { t } = useI18n()
// Open on the first render, which is what a prerendered page and a laptop
// both expect; the reader's own choice arrives after mount, for the reason
// `useSpan` reads there. A phone always starts shut: its sidebar is a sheet
@@ -594,16 +598,19 @@ export function Frame({
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
<Control label={open ? 'Close sidebar' : 'Open sidebar'} onPress={toggle}>
<Control
label={open ? t('Close sidebar') : t('Open sidebar')}
onPress={toggle}
>
<MARKS.sidebar size={16} />
</Control>
{back ? (
<Control label="Back" onPress={back} wide>
<Control label={t('Back')} onPress={back} wide>
<MARKS.back size={16} />
</Control>
) : null}
{forward ? (
<Control label="Forward" onPress={forward} wide>
<Control label={t('Forward')} onPress={forward} wide>
<MARKS.forward size={16} />
</Control>
) : null}
@@ -652,7 +659,7 @@ export function Frame({
<button
type="button"
data-slot="scrim"
aria-label="Close sidebar"
aria-label={t('Close sidebar')}
onClick={shut}
/>
) : null}
@@ -682,7 +689,7 @@ export function Frame({
span={span}
onSpan={setSpan}
onShut={shut}
label="Resize sidebar"
label={t('Resize sidebar')}
/>
{/* THE WORKSPACE LEADS THE SIDEBAR: switching it changes everything
below, so it sits above everything below. The account at the
@@ -700,8 +707,8 @@ export function Frame({
<a
href={door}
data-slot="home"
aria-label="Hanzo home"
title="Home"
aria-label={t('Hanzo home')}
title={t('Home')}
onClick={(e) => {
if (
!navigate ||
@@ -764,12 +771,12 @@ export function Frame({
<button
type="button"
data-frame-row=""
aria-label="Settings"
title="Settings"
aria-label={t('Settings')}
title={t('Settings')}
onClick={settings}
>
<MARKS.gear size={16} />
<span>Settings</span>
<span>{t('Settings')}</span>
</button>
) : null}
</div>
@@ -819,7 +826,7 @@ export function Frame({
span={beside}
onSpan={setBeside}
onShut={side.close}
label="Resize side panel"
label={t('Resize side panel')}
/>
{/* The column is the landmark; this is the box the room projects
into, a container and not a second region. */}
+45 -31
View File
@@ -4,8 +4,8 @@
* HanzoAppLauncher — the cross-app "9-dot grid" product switcher (Google-apps
* pattern) shared by every Hanzo surface.
*
* Self-contained by design: inline styles + inline SVG, React the only runtime
* dependency, ZERO CSS-framework coupling — so it drops into any Hanzo app
* Self-contained by design: inline styles + inline SVG, React and @hanzo/i18n
* the only runtime dependencies, ZERO CSS-framework coupling — so it drops into any Hanzo app
* (Next 15/16, Vite, React 18/19) and renders identically regardless of the
* host's Tailwind/Gui/none setup.
*
@@ -30,6 +30,7 @@
*/
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useI18n } from '@hanzo/i18n/react'
import { HANZO_APPS, HanzoGridIcon, type HanzoApp } from './hanzo-apps.tsx'
import { U } from './hanzo-registry.ts'
import { HanzoWordmark } from './mark.tsx'
@@ -86,7 +87,7 @@ export interface HanzoAppLauncherProps {
* command palette).
*/
quickSwitchKey?: string | false
/** Accessible label for the trigger. */
/** Accessible label for the trigger. Defaults to "Hanzo apps". */
label?: string
/** Size of the trigger glyph. */
size?: number
@@ -114,7 +115,7 @@ export function HanzoAppLauncher({
apps = HANZO_APPS,
align = 'start',
quickSwitchKey = 'k',
label = 'Hanzo apps',
label,
size = 18,
defaultOpen = false,
isEntitled: check,
@@ -122,6 +123,8 @@ export function HanzoAppLauncher({
trigger,
}: HanzoAppLauncherProps) {
useShellStyles()
const { t } = useI18n()
const named = label ?? t('Hanzo apps')
const [open, setOpen] = useState(defaultOpen)
const [hover, setHover] = useState(false)
@@ -229,12 +232,13 @@ export function HanzoAppLauncher({
const filtering = q.length > 0
const visible = useMemo(() => {
if (!filtering) return apps
// The words the tiles show are the words searched.
return apps.filter(
(a) =>
a.label.toLowerCase().includes(q) ||
(a.description ?? '').toLowerCase().includes(q)
t(a.label).toLowerCase().includes(q) ||
(a.description ? t(a.description) : '').toLowerCase().includes(q)
)
}, [apps, filtering, q])
}, [apps, filtering, q, t])
// When not filtering, pinned apps get their own prominent row above the grid.
const pinned = filtering ? [] : visible.filter((a) => a.pinned)
@@ -315,10 +319,13 @@ export function HanzoAppLauncher({
aria-current={isCurrent ? 'page' : undefined}
aria-label={
locked
? `${app.label} — locked, upgrade to unlock`
? t('{app} — locked, upgrade to unlock', { app: t(app.label) })
: app.description
? `${app.label} — ${app.description}`
: app.label
? t('{app} — {description}', {
app: t(app.label),
description: t(app.description),
})
: t(app.label)
}
onClick={() => setOpen(false)}
style={{
@@ -397,11 +404,11 @@ export function HanzoAppLauncher({
maxWidth: wide ? undefined : 92,
}}
>
{app.label}
{t(app.label)}
</span>
{wide && app.description ? (
<span style={{ fontSize: FS.xs, color: FG_DIM, lineHeight: 1.25 }}>
{app.description}
{t(app.description)}
</span>
) : null}
</span>
@@ -431,8 +438,8 @@ export function HanzoAppLauncher({
aria-haspopup="true"
aria-expanded={open}
aria-controls={open ? panelId : undefined}
aria-label={label}
title={label}
aria-label={named}
title={named}
style={{
...control(),
width: trigger ? 'auto' : 34,
@@ -450,7 +457,7 @@ export function HanzoAppLauncher({
ref={panelRef}
id={panelId}
data-hanzo-shell=""
aria-label="Hanzo apps"
aria-label={t('Hanzo apps')}
onKeyDown={onPanelKeyDown}
style={{
position: 'fixed',
@@ -478,7 +485,7 @@ export function HanzoAppLauncher({
}}
>
<HanzoWordmark size={16} />
<span style={LABEL}>Apps</span>
<span style={LABEL}>{t('Apps')}</span>
</div>
{/* Filter */}
@@ -486,8 +493,8 @@ export function HanzoAppLauncher({
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter apps…"
aria-label="Filter apps"
placeholder={t('Filter apps…')}
aria-label={t('Filter apps')}
autoComplete="off"
spellCheck={false}
style={{
@@ -541,7 +548,7 @@ export function HanzoAppLauncher({
fontSize: FS.sm,
}}
>
No apps match “{query.trim()}”.
{t('No apps match “{query}”.', { query: query.trim() })}
</div>
)}
@@ -556,18 +563,25 @@ export function HanzoAppLauncher({
fontSize: FS.xs,
}}
>
<kbd
style={{
fontFamily: 'inherit',
border: `1px solid ${BORDER}`,
borderRadius: R.row,
padding: '1px 6px',
background: CHROME.raised,
}}
>
⌘{quickSwitchKey.toUpperCase()}
</kbd>
<span style={{ marginInlineStart: 6 }}>to switch</span>
{/* One sentence with the chord inside it, so a language that
puts the key last can. */}
<span>
{t('{key} to switch', {
key: (
<kbd
style={{
fontFamily: 'inherit',
border: `1px solid ${BORDER}`,
borderRadius: R.row,
padding: '1px 6px',
background: CHROME.raised,
}}
>
⌘{quickSwitchKey.toUpperCase()}
</kbd>
),
})}
</span>
</div>
) : null}
</div>,
+37 -28
View File
@@ -20,6 +20,7 @@
* second filled element competing with the header's one white CTA.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useI18n, type Say } from '@hanzo/i18n/react'
import { TRY_HANZO_GROUPS, U, type ProductCategory } from './hanzo-registry.ts'
import { search, type Match } from './search.ts'
import { CHROME, CTRL_H, FS, TAP_H, control, controlHover, ghostHover } from './theme.ts'
@@ -99,15 +100,21 @@ type Entry = HanzoCommandEntry & { match?: Match; ask?: boolean }
const LIST_ID = 'hanzo-palette-list'
const rowId = (i: number) => `hanzo-palette-row-${i}`
/** The products and the downloads, which every Hanzo surface can open. */
function doors(): HanzoCommandEntry[] {
/**
* The products and the downloads, which every Hanzo surface can open.
*
* In the reader's language, because what is drawn is what is searched: a row
* found by its English name and shown under another would light characters the
* reader never typed.
*/
function doors(t: Say): HanzoCommandEntry[] {
return TRY_HANZO_GROUPS.flatMap((group) =>
group.items.map((item) => ({
id: `door/${group.id}/${item.id}`,
title: item.label,
title: t(item.label),
href: item.href,
hint: item.hint,
group: group.id === 'install' ? 'Install' : 'Open',
hint: item.hint ? t(item.hint) : undefined,
group: group.id === 'install' ? t('Install') : t('Open'),
external: true,
glyph: item.glyph,
}))
@@ -115,23 +122,23 @@ function doors(): HanzoCommandEntry[] {
}
/** The taxonomy, flattened. A category's own page is a record too. */
function products(categories: ProductCategory[]): HanzoCommandEntry[] {
function products(categories: ProductCategory[], t: Say): HanzoCommandEntry[] {
return categories.flatMap((category) => [
{
id: `cat/${category.id}`,
title: category.label,
title: t(category.label),
href: category.href,
hint: category.tagline,
group: 'Products',
hint: category.tagline ? t(category.tagline) : undefined,
group: t('Products'),
keywords: 'category',
},
...category.items.map((item) => ({
id: `${category.id}/${item.id}`,
title: item.label,
title: t(item.label),
href: item.href,
hint: item.hint,
group: category.label,
keywords: category.label,
hint: item.hint ? t(item.hint) : undefined,
group: t(category.label),
keywords: t(category.label),
external: /^https?:\/\//.test(item.href),
glyph: item.glyph,
})),
@@ -147,6 +154,7 @@ export function HanzoCommandPalette({
onAsk,
onNavigate,
}: HanzoCommandPaletteProps) {
const { t } = useI18n()
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
const open = controlledOpen ?? uncontrolledOpen
const setOpen = useCallback(
@@ -190,9 +198,9 @@ export function HanzoCommandPalette({
const at = new Map<string, number>()
const out: HanzoCommandEntry[] = []
for (const entry of [
...doors(),
...doors(t),
...(commands ?? []),
...products(categories ?? []),
...products(categories ?? [], t),
]) {
const seen = at.get(entry.href)
if (seen === undefined) {
@@ -203,7 +211,7 @@ export function HanzoCommandPalette({
}
}
return out
}, [commands, categories])
}, [commands, categories, t])
const question = query.trim()
@@ -224,14 +232,14 @@ export function HanzoCommandPalette({
if (question) {
hits.push({
id: 'ask',
title: askLabel(question),
title: t(...askLabel(question)),
href: askHref,
group: 'Ask',
group: t('Ask'),
ask: true,
})
}
return hits
}, [index, query, mode, question, askHref])
}, [index, query, mode, question, askHref, t])
const askQuestion = useAsk({ askHref, onAsk, onNavigate, close })
const ask = useCallback(() => askQuestion(question), [askQuestion, question])
@@ -283,18 +291,18 @@ export function HanzoCommandPalette({
const anyMark = entries.some((e) => !!e.glyph)
return (
<PaletteShell open={open} onClose={close} label="Search Hanzo or ask AI">
<PaletteShell open={open} onClose={close} label={t('Search Hanzo or ask AI')}>
<PaletteBar edge="top">
<span
role="tablist"
aria-label="Palette mode"
aria-label={t('Palette mode')}
style={{ display: 'flex', gap: 2 }}
>
<ModeTab mode="filter" active={mode === 'filter'} onPick={pick}>
Filter
{t('Filter')}
</ModeTab>
<ModeTab mode="ask" active={mode === 'ask'} onPick={pick}>
Ask AI
{t('Ask AI')}
</ModeTab>
</span>
<kbd style={KBD}>ESC</kbd>
@@ -304,7 +312,7 @@ export function HanzoCommandPalette({
value={query}
onChange={setQuery}
onKeyDown={onKeyDown}
placeholder={mode === 'ask' ? 'Ask Hanzo AI anything' : 'Search Hanzo'}
placeholder={mode === 'ask' ? t('Ask Hanzo AI anything') : t('Search Hanzo')}
inputRef={focusField}
listId={LIST_ID}
activeId={entries.length ? rowId(cursor) : undefined}
@@ -316,8 +324,8 @@ export function HanzoCommandPalette({
index={0}
id={rowId(0)}
selected
title={question ? `Ask AI: ${question}` : 'Ask Hanzo AI'}
description={question ? undefined : 'Open a chat and ask anything'}
title={question ? t(...askLabel(question)) : t('Ask Hanzo AI')}
description={question ? undefined : t('Open a chat and ask anything')}
onSelect={ask}
onHover={() => {}}
/>
@@ -417,11 +425,12 @@ export function HanzoCommandTrigger({
*/
height?: number
}) {
const { t } = useI18n()
return (
<button
type="button"
onClick={onOpen}
aria-label="Search Hanzo or ask AI"
aria-label={t('Search Hanzo or ask AI')}
aria-keyshortcuts="Meta+K Control+K"
style={{
...control(false, height),
@@ -451,7 +460,7 @@ export function HanzoCommandTrigger({
{compact ? null : (
<>
{/* The word, so the control is legible without decoding a glyph. */}
<span>Search</span>
<span>{t('Search')}</span>
{/* The chord spoken, not boxed. KBD is a keycap for hints sitting on
a flat panel; inside a control that already has its own edge it
would draw a second box within the first. */}
+10 -5
View File
@@ -14,9 +14,11 @@
* Everything is DATA from the registry (`HANZO_FOOTER_COLUMNS` +
* `HANZO_FOOTER_BOTTOM`) so the footer is byte-identical across surfaces; the
* current product is highlighted in the PRODUCTS column. Self-contained: inline
* styles + theme.ts tokens, React the only runtime dep — drops into any host.
* styles + theme.ts tokens, React and @hanzo/i18n the only runtime deps — drops
* into any host.
*/
import React from 'react'
import { useI18n } from '@hanzo/i18n/react'
import { HanzoMark } from './mark.tsx'
import {
HANZO_FOOTER_BOTTOM,
@@ -43,6 +45,7 @@ export interface HanzoFooterProps {
export function HanzoFooter({ currentProductId, className, visible }: HanzoFooterProps) {
useShellStyles()
const { t } = useI18n()
const shown = (href: string) => (visible ? visible(href) : true)
const columns = HANZO_FOOTER_COLUMNS.map((col) => ({
...col,
@@ -77,8 +80,8 @@ export function HanzoFooter({ currentProductId, className, visible }: HanzoFoote
}}
>
{columns.map((col) => (
<nav key={col.id} aria-label={col.title}>
<div style={{ ...LABEL, marginBottom: 14 }}>{col.title}</div>
<nav key={col.id} aria-label={t(col.title)}>
<div style={{ ...LABEL, marginBottom: 14 }}>{t(col.title)}</div>
<ul
style={{
listStyle: 'none',
@@ -150,6 +153,7 @@ export function HanzoFooter({ currentProductId, className, visible }: HanzoFoote
/* ── Pieces ──────────────────────────────────────────────────────────────── */
function FooterLink({ link, current }: { link: HanzoLink; current: boolean }) {
const { t } = useI18n()
return (
<a
href={link.href}
@@ -169,12 +173,13 @@ function FooterLink({ link, current }: { link: HanzoLink; current: boolean }) {
;(e.currentTarget as HTMLElement).style.color = current ? ACCENT : CHROME.fgMuted
}}
>
{link.label}
{t(link.label)}
</a>
)
}
function LegalLink({ link }: { link: HanzoLink }) {
const { t } = useI18n()
return (
<a
href={link.href}
@@ -195,7 +200,7 @@ function LegalLink({ link }: { link: HanzoLink }) {
;(e.currentTarget as HTMLElement).style.color = CHROME.fgMuted
}}
>
{link.label}
{t(link.label)}
</a>
)
}
+39 -29
View File
@@ -28,11 +28,13 @@
* used to be a field buried inside the Products mega-menu, which meant the only
* way to search products was to first open the menu you were trying to skip.
*
* Self-contained: inline styles + theme.ts tokens, React the only runtime dep —
* drops into Next / Vite / vanilla hosts with zero provider/setup. Sticky, dark
* chrome, fully keyboard-accessible.
* Self-contained: inline styles + theme.ts tokens, React and @hanzo/i18n the only
* runtime deps — drops into Next / Vite / vanilla hosts with zero provider/setup
* (with no I18nProvider every word reads in English). Sticky, dark chrome, fully
* keyboard-accessible.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@hanzo/i18n/react'
import { HanzoWordmark } from './mark.tsx'
import { MeetHanzoMenu } from './MeetHanzoMenu.tsx'
import { TryHanzoMenu } from './TryHanzoMenu.tsx'
@@ -304,6 +306,7 @@ export function HanzoHeader({
className,
}: HanzoHeaderProps) {
useShellStyles()
const { t } = useI18n()
const s = resolveSurface(surface)
// The bar collapses to the sheet when it does not FIT, not at a width someone
// guessed. Measured on hanzo.ai: the desktop row fits at 1280 and overlaps
@@ -461,13 +464,13 @@ export function HanzoHeader({
.filter((link): link is HanzoLink => Boolean(link))
.map((link) => ({
id: `nav/${link.id}`,
title: link.label,
title: t(link.label),
href: link.href,
hint: link.hint,
group: 'Pages',
hint: link.hint ? t(link.hint) : undefined,
group: t('Pages'),
external: outward(link.href, s.host),
})),
[localNav, s.secondaryCTA, s.primaryCTA]
[localNav, s.secondaryCTA, s.primaryCTA, t]
)
const searchIndex = React.useMemo(
@@ -576,7 +579,7 @@ export function HanzoHeader({
<div style={{ flex: 1 }} />
{hasSearch ? <HanzoCommandTrigger onOpen={openSearch} compact /> : null}
<IconButton
label={mobileOpen ? 'Close menu' : 'Open menu'}
label={mobileOpen ? t('Close menu') : t('Open menu')}
expanded={mobileOpen}
onClick={() => {
menu.set(null)
@@ -591,7 +594,7 @@ export function HanzoHeader({
{/* ── Products ⌄ (rich mega-menu) — only when a taxonomy is provided ── */}
{hasProducts ? (
<MenuTrigger
label={productsLabel}
label={t(productsLabel)}
open={menu.key === 'products'}
reach={menu.trigger('products')}
onStepIn={() => menu.set('products')}
@@ -601,7 +604,7 @@ export function HanzoHeader({
{/* ── Local nav ── */}
<nav
aria-label={`${s.brandName} navigation`}
aria-label={t('{brand} navigation', { brand: s.brandName })}
style={{ display: 'flex', alignItems: 'center', gap: 2, minWidth: 0 }}
>
{localNav.map((link) => {
@@ -875,6 +878,7 @@ function NavLink({
current?: boolean
dim?: boolean
}) {
const { t } = useI18n()
// Hover comes from the shared token, not from here. Hand-rolling it is what
// let this link drift from the trigger beside it (a background lift, and a
// brightening that stopped short of white).
@@ -884,7 +888,7 @@ function NavLink({
aria-current="page"
style={{ ...control(true), fontWeight: 500, ...recede(dim) }}
>
{link.label}
{t(link.label)}
</span>
)
}
@@ -894,7 +898,7 @@ function NavLink({
style={{ ...control(), fontWeight: 500, ...recede(dim) }}
{...controlHover()}
>
{link.label}
{t(link.label)}
</a>
)
}
@@ -952,6 +956,7 @@ function NavMenu({
current?: boolean
dim?: boolean
}) {
const { t } = useI18n()
const { onClick, ...pointer } = reach
const rove = useRove(open, onClose, autoFocus)
const panel = `hanzo-nav-${link.id}`
@@ -983,7 +988,7 @@ function NavMenu({
{...controlHover(open)}
>
<Glyph name={link.glyph} />
{link.label}
{t(link.label)}
<Chevron open={open} />
</Trigger>
{open ? (
@@ -1020,7 +1025,7 @@ function NavMenu({
id={panel}
role="dialog"
data-hanzo-plane=""
aria-label={link.label}
aria-label={t(link.label)}
ref={rove.ref}
onKeyDown={rove.onKeyDown}
onFocus={rove.onFocus}
@@ -1054,7 +1059,9 @@ function NavMenu({
minWidth: 0,
}}
>
<ColumnTitle>Explore {link.label}</ColumnTitle>
<ColumnTitle>
{t('Explore {label}', { label: t(link.label) })}
</ColumnTitle>
{items.map((item) => (
<a
key={item.id}
@@ -1073,7 +1080,7 @@ function NavMenu({
}}
{...ghostHover()}
>
{item.label}
{t(item.label)}
{item.external ? <Outlink /> : null}
</a>
))}
@@ -1088,7 +1095,7 @@ function NavMenu({
minWidth: 0,
}}
>
<ColumnTitle>{group.title}</ColumnTitle>
<ColumnTitle>{t(group.title)}</ColumnTitle>
{group.items.map((item) => (
<a
key={item.id}
@@ -1104,7 +1111,7 @@ function NavMenu({
}}
{...ghostHover()}
>
{item.label}
{t(item.label)}
{item.external ? <Outlink /> : null}
</a>
))}
@@ -1130,7 +1137,7 @@ function NavMenu({
>
<Glyph name={item.glyph} reserve={anyMark} />
<span style={{ minWidth: 0 }}>
{item.label}
{t(item.label)}
{item.external ? <Outlink /> : null}
{item.hint ? (
<span
@@ -1142,7 +1149,7 @@ function NavMenu({
color: CHROME.fgDim,
}}
>
{item.hint}
{t(item.hint)}
</span>
) : null}
</span>
@@ -1220,6 +1227,7 @@ function CTA({
*/
align?: 'center' | 'start'
}) {
const { t } = useI18n()
const filled = variant === 'filled'
const justify =
align === 'start'
@@ -1231,7 +1239,7 @@ function CTA({
if (current) {
return (
<span aria-current="page" style={{ ...cta(filled, height), ...justify }}>
{link.label}
{t(link.label)}
</span>
)
}
@@ -1253,7 +1261,7 @@ function CTA({
else e.currentTarget.style.color = CHROME.fgMuted
}}
>
{link.label}
{t(link.label)}
{away ? <Outlink /> : null}
</a>
)
@@ -1285,6 +1293,7 @@ function CTATrigger({
controls: string
current?: boolean
}) {
const { t } = useI18n()
const { onClick, ...pointer } = reach
// On the surface the action names, there is no href to fall back to — so it
// is a BUTTON, which keeps it focusable and operable while offering nobody a
@@ -1308,7 +1317,7 @@ function CTATrigger({
onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')}
{...pointer}
>
{link.label}
{t(link.label)}
<Chevron open={open} />
</button>
)
@@ -1333,7 +1342,7 @@ function CTATrigger({
onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')}
{...pointer}
>
{link.label}
{t(link.label)}
<Chevron open={open} />
</a>
)
@@ -1383,6 +1392,7 @@ function MobileSheet({
top: number
onClose: () => void
}) {
const { t } = useI18n()
const hasProducts = !!productsTaxonomy && productsTaxonomy.length > 0
const localNav = withoutProductsDup(surface.localNav, hasProducts)
const isHere = (href: string) => here(href, currentHref, surface.host)
@@ -1446,7 +1456,7 @@ function MobileSheet({
<div
role="dialog"
data-hanzo-shell=""
aria-label={`${surface.brandName} menu`}
aria-label={t('{brand} menu', { brand: surface.brandName })}
style={{
...PANEL,
position: 'fixed',
@@ -1527,7 +1537,7 @@ function MobileSheet({
{...ghostHover(false)}
>
<BackGlyph />
{path.length > 1 ? path[path.length - 2]!.label : 'Home'}
{path.length > 1 ? t(path[path.length - 2]!.label) : t('Home')}
</button>
{/* `LABEL`, like every other head in the chrome. It had its own
size and its own ink here, which made the one word naming the
@@ -1535,7 +1545,7 @@ function MobileSheet({
rank below the same word in the mega-menu. 12px of inset, not
8, because the rows below carry `sheetRow`'s 12px padding and
a head has no hover surface to bleed. */}
<p style={{ ...LABEL, margin: '10px 12px 2px' }}>{here_.label}</p>
<p style={{ ...LABEL, margin: '10px 12px 2px' }}>{t(here_.label)}</p>
</>
) : null}
@@ -1561,7 +1571,7 @@ function MobileSheet({
>
<span style={{ ...glyphRow, alignItems: 'center' }}>
<Glyph name={node.glyph} reserve={anyMark} />
{node.label}
{t(node.label)}
</span>
{/* Drill in: the chevron points along the line, so it turns
with it under RTL. The mirror sits outside the rotation. */}
@@ -1585,7 +1595,7 @@ function MobileSheet({
>
<span style={{ ...glyphRow, alignItems: 'center' }}>
<Glyph name={node.glyph} reserve={anyMark} />
{node.label}
{t(node.label)}
{/* The marker is the promise that this leaves the surface, and
it belongs wherever the row does — the desktop menus have
carried it since they were written, so a phone without it
+13 -10
View File
@@ -22,6 +22,7 @@
* every width and in every host theme (see `PANEL` in theme.ts).
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n, type Say } from '@hanzo/i18n/react'
import {
CHROME,
FS,
@@ -69,7 +70,7 @@ export interface HanzoAuth {
accountHref?: string
/** Extra account-menu rows (Billing, Settings, …), above Sign out. */
items?: HanzoLink[]
/** Label for the signed-out action. */
/** Label for the signed-out action. Defaults to "Sign in". */
label?: string
/**
* The viewer's plan, already resolved by the host (`usePlan()` higher in the
@@ -89,6 +90,7 @@ export interface HanzoIdentityProps {
}
export function HanzoIdentity({ auth }: HanzoIdentityProps) {
const { t } = useI18n()
const {
user,
onSignIn,
@@ -96,7 +98,7 @@ export function HanzoIdentity({ auth }: HanzoIdentityProps) {
signInHref = U.login,
accountHref = U.account,
items,
label = 'Sign in',
label,
plan,
billing,
} = auth
@@ -119,7 +121,7 @@ export function HanzoIdentity({ auth }: HanzoIdentityProps) {
}
{...controlHover()}
>
{label}
{label ?? t('Sign in')}
</a>
)
}
@@ -127,7 +129,7 @@ export function HanzoIdentity({ auth }: HanzoIdentityProps) {
return (
<AccountMenu
user={user}
items={items ?? defaultItems(accountHref)}
items={items ?? defaultItems(accountHref, t)}
onSignOut={onSignOut}
plan={plan}
billing={billing}
@@ -150,11 +152,12 @@ function AccountMenu({
plan?: Plan
billing?: Omit<UsePlanOptions, 'enabled'>
}) {
const { t } = useI18n()
const [open, setOpen] = useState(false)
const boxRef = useRef<HTMLDivElement>(null)
const close = useCallback(() => setOpen(false), [])
const rove = useRove(open, close, true)
const name = user.name || user.email || 'Account'
const name = user.name || user.email || t('Account')
// Read only while the menu is open, and not at all when the host resolved the
// plan. Nobody needs it resolved on every page load of every property, and
@@ -190,7 +193,7 @@ function AccountMenu({
{open ? (
<div
role="menu"
aria-label="Account"
aria-label={t('Account')}
ref={rove.ref}
onKeyDown={rove.onKeyDown}
onFocus={rove.onFocus}
@@ -264,7 +267,7 @@ function AccountMenu({
}}
{...ghostHover()}
>
Sign out
{t('Sign out')}
</button>
) : null}
</div>
@@ -274,7 +277,7 @@ function AccountMenu({
}
/** Where an account menu goes when the host names nothing of its own. */
const defaultItems = (accountHref: string): HanzoLink[] => [
{ id: 'account', label: 'Account', href: accountHref },
{ id: 'billing', label: 'Billing', href: U.billing },
const defaultItems = (accountHref: string, t: Say): HanzoLink[] => [
{ id: 'account', label: t('Account'), href: accountHref },
{ id: 'billing', label: t('Billing'), href: U.billing },
]
+6 -3
View File
@@ -14,6 +14,7 @@
* identical. Self-contained: inline styles + theme.ts tokens.
*/
import React from 'react'
import { useI18n } from '@hanzo/i18n/react'
import { resolveSurface } from './HanzoHeader.tsx'
import { type HanzoLink, type HanzoSurface } from './hanzo-registry.ts'
import { CHROME, FS, TAP_H, cta } from './theme.ts'
@@ -27,6 +28,7 @@ export interface HanzoPreFooterCTAProps {
export function HanzoPreFooterCTA({ surface, className }: HanzoPreFooterCTAProps) {
useShellStyles()
const { t } = useI18n()
const s = resolveSurface(surface)
const { heading, actions } = s.preFooter
@@ -34,7 +36,7 @@ export function HanzoPreFooterCTA({ surface, className }: HanzoPreFooterCTAProps
<section
data-hanzo-shell=""
className={className}
aria-label={heading}
aria-label={t(heading)}
style={{
borderTop: `1px solid ${CHROME.border}`,
background: CHROME.bg,
@@ -64,7 +66,7 @@ export function HanzoPreFooterCTA({ surface, className }: HanzoPreFooterCTAProps
lineHeight: 1.15,
}}
>
{heading}
{t(heading)}
</h2>
<div
style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 12 }}
@@ -91,6 +93,7 @@ function ActionButton({
link: HanzoLink
variant: 'ghost' | 'filled'
}) {
const { t } = useI18n()
const filled = variant === 'filled'
return (
<a
@@ -105,7 +108,7 @@ function ActionButton({
else e.currentTarget.style.background = 'transparent'
}}
>
{link.label}
{t(link.label)}
</a>
)
}
+4 -2
View File
@@ -15,6 +15,7 @@
* only reports that one is running. See <UserOrgDropdown> for where it begins.
*/
import React from 'react'
import { useI18n } from '@hanzo/i18n/react'
import { ACCENT, CHROME, CTRL_H, FS, R } from './theme.ts'
import type { HanzoOrg } from './types.ts'
@@ -26,6 +27,7 @@ export interface MasqueradeProps {
}
export function Masquerade({ org, onStop }: MasqueradeProps) {
const { t } = useI18n()
return (
<div
data-hanzo-shell=""
@@ -59,13 +61,13 @@ export function Masquerade({ org, onStop }: MasqueradeProps) {
}}
>
{/* Named, not hinted. "Acting as" is the whole state in two words. */}
Acting as {org.name}
{t('Acting as {org}', { org: org.name })}
</span>
{onStop ? (
<button
type="button"
onClick={onStop}
aria-label={`Stop acting as ${org.name}`}
aria-label={t('Stop acting as {org}', { org: org.name })}
style={{
display: 'inline-flex',
alignItems: 'center',
+12 -7
View File
@@ -33,7 +33,8 @@
*
* Controlled-open (props `open`/`onClose`/`anchor`) so a header can drive it;
* also usable standalone. Self-contained: inline styles + theme.ts tokens,
* React the only runtime dep — drops into any host with zero setup. Fully
* React and @hanzo/i18n the only runtime deps — drops into any host with zero
* setup. Fully
* keyboard-accessible: Esc closes and returns focus to the previously-focused
* element; ↑/↓/←/→/Home/End rove the items; the current product is highlighted
* with the accent + `aria-current`. See `useRove` for the focus contract, and
@@ -41,6 +42,7 @@
*/
import React, { useCallback, useMemo } from 'react'
import type { PointerEventHandler } from 'react'
import { useI18n } from '@hanzo/i18n/react'
import {
HANZO_FLAGSHIP,
MEET_HANZO_GROUPS,
@@ -133,6 +135,7 @@ export function MeetHanzoMenu({
stage,
}: MeetHanzoMenuProps) {
useShellStyles()
const { t } = useI18n()
const stacked = useIsMobile(STACK_BELOW)
const close = useCallback(() => onClose?.(), [onClose])
const rove = useRove(!!open, close, autoFocus)
@@ -184,7 +187,7 @@ export function MeetHanzoMenu({
role="dialog"
data-hanzo-plane=""
aria-modal="false"
aria-label="Meet Hanzo"
aria-label={t('Meet Hanzo')}
className={className}
onKeyDown={rove.onKeyDown}
onFocus={rove.onFocus}
@@ -224,14 +227,14 @@ export function MeetHanzoMenu({
>
{/* ── Products — one dense column ── */}
<section
aria-label="Products"
aria-label={t('Products')}
data-hanzo-menu-col=""
style={{
paddingInlineEnd: stacked ? 0 : 22,
borderInlineEnd: stacked ? undefined : `1px solid ${CHROME.borderSoft}`,
}}
>
<SectionLabel>Products</SectionLabel>
<SectionLabel>{t('Products')}</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{products.map((p) => (
<ProductRow
@@ -263,7 +266,7 @@ export function MeetHanzoMenu({
>
{columns.map((group) => (
<div key={group.id} data-hanzo-menu-col="">
<SectionLabel>{group.title}</SectionLabel>
<SectionLabel>{t(group.title)}</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{group.items.map((item) => (
<LinkRow
@@ -311,6 +314,7 @@ function ProductRow({
current: boolean
onNavigate: () => void
}) {
const { t } = useI18n()
return (
<a
href={product.href}
@@ -339,7 +343,7 @@ function ProductRow({
color: CHROME.fgDim,
}}
>
{product.tagline}
{t(product.tagline)}
</span>
</span>
</a>
@@ -355,6 +359,7 @@ function LinkRow({
current: boolean
onNavigate: () => void
}) {
const { t } = useI18n()
return (
<a
href={link.href}
@@ -364,7 +369,7 @@ function LinkRow({
{...ghostHover(current)}
>
<Glyph name={link.glyph} />
{link.label}
{t(link.label)}
</a>
)
}
+22 -19
View File
@@ -17,6 +17,8 @@
* that app does.
*/
import React, { useEffect, useRef, useState } from 'react'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { MARKS } from './glyph.tsx'
import { useFrameStyles } from './shellStyles.ts'
import { FRAME, FS } from './theme.ts'
@@ -52,11 +54,11 @@ const INBOX = '/v1/team/inbox'
/** A Map, not an object: a reason the server names is data, and `__proto__` is a key. */
const REASON = new Map<string, string>([
['mention', 'Mentioned you'],
['dm', 'Direct message'],
['reply', 'Replied in a thread'],
['comment', 'Commented'],
['assigned', 'Assigned to you'],
['mention', phrase('Mentioned you')],
['dm', phrase('Direct message')],
['reply', phrase('Replied in a thread')],
['comment', phrase('Commented')],
['assigned', phrase('Assigned to you')],
])
/**
@@ -82,17 +84,18 @@ function when(ms: number | undefined, now: Date): string {
)
}
/** A refusal in a sentence: what failed, not a stack. */
/** A refusal in a sentence: what failed, not a stack. The page draws it through `t`. */
function say(e: unknown): string {
const status = (e as { status?: number })?.status
if (status === 401 || status === 403)
return 'Notifications are not open to this session.'
return 'Your notifications could not be read just now.'
return phrase('Notifications are not open to this session.')
return phrase('Your notifications could not be read just now.')
}
/** The page's body: what the list holds, read and kept. */
export function Notifications({ call, onOpen }: NotificationsProps) {
useFrameStyles()
const { t } = useI18n()
const [items, setItems] = useState<Notice[] | null>(null)
const [unread, setUnread] = useState(0)
const [wrong, setWrong] = useState<string | null>(null)
@@ -184,12 +187,12 @@ export function Notifications({ call, onOpen }: NotificationsProps) {
return (
<section
data-slot="notifications"
aria-label="Notifications"
aria-label={t('Notifications')}
style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 720 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minHeight: 32 }}>
<span style={{ flex: 1, fontSize: FS.base, color: FRAME.soft }}>
{items === null ? '' : unread ? `${unread} unread` : 'All read'}
{items === null ? '' : unread ? t('{n} unread', { n: unread }) : t('All read')}
</span>
{unread > 0 ? (
<button
@@ -199,23 +202,23 @@ export function Notifications({ call, onOpen }: NotificationsProps) {
disabled={busy}
style={{ width: 'auto', color: FRAME.ink }}
>
<span>{busy ? 'Marking' : 'Mark all read'}</span>
<span>{busy ? t('Marking') : t('Mark all read')}</span>
</button>
) : null}
</div>
{wrong ? (
<p role="alert" style={{ margin: 0, fontSize: FS.base, color: FRAME.ink }}>
{wrong}
{t(wrong)}
</p>
) : null}
{items === null ? (
wrong ? null : (
note('Reading your notifications.')
note(t('Reading your notifications.'))
)
) : items.length === 0 ? (
note('Nothing yet. Mentions, direct messages and replies land here.')
note(t('Nothing yet. Mentions, direct messages and replies land here.'))
) : (
<ul
style={{
@@ -240,7 +243,7 @@ export function Notifications({ call, onOpen }: NotificationsProps) {
}}
>
<span
aria-label={n.read ? undefined : 'Unread'}
aria-label={n.read ? undefined : t('Unread')}
style={{
flex: 'none',
width: 8,
@@ -260,7 +263,7 @@ export function Notifications({ call, onOpen }: NotificationsProps) {
>
<span style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
<span style={{ flex: 1, fontWeight: n.read ? 400 : 600 }}>
{REASON.get(String(n.reason ?? '')) ?? 'Notification'}
{t(REASON.get(String(n.reason ?? '')) ?? phrase('Notification'))}
</span>
<span style={{ flex: 'none', fontSize: 11, color: FRAME.soft }}>
{when(n.createdOn, now)}
@@ -280,7 +283,7 @@ export function Notifications({ call, onOpen }: NotificationsProps) {
</span>
) : (
<span style={{ color: FRAME.soft }}>
The message it was about is gone.
{t('The message it was about is gone.')}
</span>
)}
</span>
@@ -288,8 +291,8 @@ export function Notifications({ call, onOpen }: NotificationsProps) {
<button
type="button"
data-frame-icon=""
aria-label="Archive"
title="Archive"
aria-label={t('Archive')}
title={t('Archive')}
onClick={() => void archive(n)}
style={{ minWidth: 36, minHeight: 36 }}
>
+21 -10
View File
@@ -11,6 +11,7 @@
* what a product taxonomy does not.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useI18n, type Say } from '@hanzo/i18n/react'
import { HANZO_APPS, type HanzoApp } from './hanzo-apps.tsx'
import { U } from './hanzo-registry.ts'
import { search, type Match } from './search.ts'
@@ -82,15 +83,19 @@ export type OrgCommandPaletteProps = {
type Row = CommandItem & { match?: Match; ask?: boolean }
/** Cross-app navigation, derived from the apps this org can reach. */
function crossAppCommands(apps: HanzoApp[], currentAppId?: string): CommandItem[] {
function crossAppCommands(
apps: HanzoApp[],
t: Say,
currentAppId?: string
): CommandItem[] {
return apps
.filter((app) => app.id !== currentAppId)
.map((app) => ({
id: `app-${app.id}`,
title: app.label,
description: app.description,
title: t(app.label),
description: app.description ? t(app.description) : undefined,
href: app.href,
category: 'Hanzo Apps',
category: t('Hanzo Apps'),
external: true,
keywords: [app.id, app.label.toLowerCase()],
}))
@@ -107,6 +112,7 @@ export function OrgCommandPalette({
onAsk,
}: OrgCommandPaletteProps) {
useShellStyles()
const { t } = useI18n()
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
const open = controlledOpen ?? uncontrolledOpen
const setOpen = useCallback(
@@ -134,7 +140,7 @@ export function OrgCommandPalette({
// before you know what is in it. Once a query orders rows by how well each
// answers it, group heads would tear that order apart, so they go.
const flat = useMemo<Row[]>(() => {
const all = [...appCommands, ...crossAppCommands(apps ?? HANZO_APPS, currentAppId)]
const all = [...appCommands, ...crossAppCommands(apps ?? HANZO_APPS, t, currentAppId)]
if (!question) {
const byCategory = new Map<string, CommandItem[]>()
for (const cmd of all) {
@@ -153,9 +159,14 @@ export function OrgCommandPalette({
// Last, always: a command list is finite and a question is not, so the row
// that hands it to the AI is what keeps an unmatched query from being a
// dead end. Same label and same behaviour as the public palette's.
hits.push({ id: 'ask', title: askLabel(question), category: 'Ask', ask: true })
hits.push({
id: 'ask',
title: t(...askLabel(question)),
category: t('Ask'),
ask: true,
})
return hits
}, [appCommands, apps, currentAppId, query, question])
}, [appCommands, apps, currentAppId, query, question, t])
const askQuestion = useAsk({ askHref, onAsk, onNavigate, close })
@@ -194,9 +205,9 @@ export function OrgCommandPalette({
const grouped = !question
return (
<PaletteShell open={open} onClose={close} label="Command palette">
<PaletteShell open={open} onClose={close} label={t('Command palette')}>
<PaletteBar edge="top">
<span>Commands</span>
<span>{t('Commands')}</span>
<kbd style={KBD}>ESC</kbd>
</PaletteBar>
@@ -204,7 +215,7 @@ export function OrgCommandPalette({
value={query}
onChange={setQuery}
onKeyDown={onKeyDown}
placeholder="Search commands"
placeholder={t('Search commands')}
inputRef={focusField}
listId={LIST_ID}
activeId={flat.length ? rowId(index) : undefined}
+7 -5
View File
@@ -1,6 +1,7 @@
'use client'
import React, { useState, useCallback } from 'react'
import { useI18n } from '@hanzo/i18n/react'
import { HanzoWordmark } from './mark.tsx'
import { HanzoAppLauncher } from './HanzoAppLauncher.tsx'
import { UserOrgDropdown, type UserOrgDropdownProps } from './UserOrgDropdown.tsx'
@@ -338,6 +339,7 @@ export function OrgHeader({
hideLauncher,
}: OrgHeaderProps) {
useShellStyles()
const { t } = useI18n()
const [refreshing, setRefreshing] = useState(false)
// Resolve the current org slug for white-label domain routing.
@@ -398,7 +400,7 @@ export function OrgHeader({
*/}
<a
href={accountHref}
aria-label="Account"
aria-label={t('Account')}
style={{
display: 'inline-flex',
flexShrink: 0,
@@ -470,8 +472,8 @@ export function OrgHeader({
type="button"
onClick={handleHardRefresh}
style={ICON_BTN}
aria-label="Hard refresh — clear all storage, cookies, cache and reload"
title="Hard refresh"
aria-label={t('Hard refresh — clear all storage, cookies, cache and reload')}
title={t('Hard refresh')}
{...ghostHover()}
>
<HardRefreshIcon spinning={refreshing} />
@@ -484,8 +486,8 @@ export function OrgHeader({
type="button"
onClick={handleSettings}
style={ICON_BTN}
aria-label="Settings"
title="Settings"
aria-label={t('Settings')}
title={t('Settings')}
{...ghostHover()}
>
<SettingsIcon />
+13 -8
View File
@@ -37,7 +37,8 @@
* the category pages.)
*
* Controlled-open (`open`/`onClose`/`anchor`) so a header can drive it. Self-
* contained: inline styles + theme.ts tokens, React the only runtime dep — the
* contained: inline styles + theme.ts tokens, React and @hanzo/i18n the only
* runtime deps — the
* glyphs are inlined lucide paths (ISC) rather than a dependency, so this still
* drops into any host with zero setup. Fully keyboard-accessible: Esc closes and
* returns focus to the trigger; ↑/↓/←/→/Home/End rove the links (category
@@ -48,6 +49,7 @@
*/
import React, { useCallback, useState } from 'react'
import type { PointerEventHandler } from 'react'
import { useI18n } from '@hanzo/i18n/react'
import { type HanzoLink, type ProductCategory } from './hanzo-registry.ts'
import {
ACCENT,
@@ -122,6 +124,7 @@ export function ProductsMegaMenu({
onPointerLeave,
}: ProductsMegaMenuProps) {
useShellStyles()
const { t } = useI18n()
const close = useCallback(() => onClose?.(), [onClose])
const rove = useRove(!!open, close, autoFocus)
@@ -145,7 +148,7 @@ export function ProductsMegaMenu({
role="dialog"
data-hanzo-plane=""
aria-modal="false"
aria-label="Products"
aria-label={t('Products')}
className={className}
onKeyDown={rove.onKeyDown}
onFocus={rove.onFocus}
@@ -214,6 +217,7 @@ function CategoryTile({
currentHref?: string
onNavigate: () => void
}) {
const { t } = useI18n()
// Hover lives on the TILE, not on each link, because the whole column lifts
// together — the glyph, the name and the leaves all brighten as one.
const [hover, setHover] = useState(false)
@@ -272,13 +276,13 @@ function CategoryTile({
minWidth: 0,
}}
>
{category.label}
{t(category.label)}
</span>
</a>
{category.tagline ? (
<p
title={category.tagline}
title={t(category.tagline)}
style={{
// ONE text column. The heading sits after the mark, so a tagline and
// a leaf list starting at the tile's own edge began 40px to its LEFT
@@ -298,7 +302,7 @@ function CategoryTile({
transition: 'color 140ms ease',
}}
>
{category.tagline}
{t(category.tagline)}
</p>
) : null}
@@ -339,6 +343,7 @@ function LeafRow({
tileLit: boolean
onNavigate: () => void
}) {
const { t } = useI18n()
const [hover, setHover] = useState(false)
// Three steps, all colour: dim at rest, one step up while its tile is lit,
@@ -371,7 +376,7 @@ function LeafRow({
transition: 'color 120ms ease',
}}
>
{link.label}
{t(link.label)}
</a>
)
}
@@ -383,8 +388,8 @@ function LeafRow({
* a slugged label, then to a neutral grid).
*
* These are lucide paths (ISC) inlined rather than imported. The shell's whole
* premise is that it drops into any host with React as its only runtime
* dependency — pulling `lucide-react` in for ten static glyphs would trade that
* premise is that it drops into any host with React and @hanzo/i18n as its only
* runtime dependencies — pulling `lucide-react` in for ten static glyphs would trade that
* for ~1500 icons the shell never renders.
*/
const GLYPHS: Record<string, React.ReactNode> = {
+6 -4
View File
@@ -20,6 +20,7 @@
* every other `hanzo*` key, in `own()`. A host without that sweep must add it.
*/
import React, { useCallback, useEffect, useState } from 'react'
import { useI18n } from '@hanzo/i18n/react'
import { FRAME, FS } from './theme.ts'
export interface ScopeProject {
@@ -120,24 +121,25 @@ export function Scope({
environment,
onEnvironment,
}: ScopeProps) {
const { t } = useI18n()
// A kept project the workspace no longer has is not the scope. Shown as All
// projects until the list says otherwise, rather than as a name nobody can see.
const known = project !== null && (projects ?? []).some((p) => p.id === project)
return (
<div
role="group"
aria-label="Scope"
aria-label={t('Scope')}
style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}
>
<select
aria-label="Project"
aria-label={t('Project')}
value={known ? (project as string) : ''}
onChange={(e) => onProject(e.target.value || null)}
disabled={projects === null}
style={SELECT}
>
<option value="">
{projects === null ? 'Reading projects' : 'All projects'}
{projects === null ? t('Reading projects') : t('All projects')}
</option>
{(projects ?? []).map((p) => (
<option key={p.id} value={p.id}>
@@ -147,7 +149,7 @@ export function Scope({
</select>
{environments.length > 0 && onEnvironment ? (
<select
aria-label="Environment"
aria-label={t('Environment')}
value={
environment && environments.includes(environment)
? environment
+8 -5
View File
@@ -27,12 +27,14 @@
* own trigger at the right edge and stays the width of its contents. The two
* shapes are how a reader tells "browse" from "go" before reading a word.
*
* Self-contained: inline styles + theme.ts tokens, React the only runtime dep.
* Self-contained: inline styles + theme.ts tokens, React and @hanzo/i18n the only
* runtime deps.
* Esc closes and returns focus; ↑/↓/←/→/Home/End rove (see `useRove`), and a
* hover opens it without stealing the caret (see `useIntent`).
*/
import React, { useCallback, useMemo } from 'react'
import type { PointerEventHandler } from 'react'
import { useI18n } from '@hanzo/i18n/react'
import { TRY_HANZO_GROUPS } from './hanzo-registry.ts'
import { MARKS } from './glyph.tsx'
import { CHROME, FS, GLASS, LABEL, R, SHADOW, Z, ghostHover, row, veil } from './theme.ts'
@@ -88,6 +90,7 @@ export function TryHanzoMenu({
onPointerLeave,
}: TryHanzoMenuProps) {
useShellStyles()
const { t } = useI18n()
const stacked = useIsMobile(STACK_BELOW)
const close = useCallback(() => onClose?.(), [onClose])
const rove = useRove(!!open, close, autoFocus)
@@ -112,7 +115,7 @@ export function TryHanzoMenu({
<div
id={id}
role="dialog"
aria-label="Try Hanzo"
aria-label={t('Try Hanzo')}
/* The card is a SIBLING of the bar, not a child, so it does not inherit
the header's marker — and without it none of shellStyles reaches here:
not the hover rules below, and not the reduced-motion silencer, which
@@ -166,7 +169,7 @@ export function TryHanzoMenu({
].map(({ g, w, hints }) =>
g ? (
<div key={g.id} style={{ minWidth: 0, width: stacked ? '100%' : w }}>
<div style={{ ...LABEL, marginBottom: 4 }}>{g.title}</div>
<div style={{ ...LABEL, marginBottom: 4 }}>{t(g.title)}</div>
{g.items.map((item) => {
const Mark = item.glyph ? MARKS[item.glyph] : undefined
const delay = seq++ * CASCADE
@@ -216,7 +219,7 @@ export function TryHanzoMenu({
>
{Mark ? <Mark size={16} /> : null}
</span>
{item.label}
{t(item.label)}
{/* Where it goes, said only while the pointer is on it —
a row of thirteen permanent arrows is noise. */}
<span
@@ -244,7 +247,7 @@ export function TryHanzoMenu({
lineHeight: 1.3,
}}
>
{item.hint}
{t(item.hint)}
</span>
) : null}
</a>
+4 -2
View File
@@ -1,6 +1,7 @@
'use client'
import React, { useState, useMemo } from 'react'
import { useI18n } from '@hanzo/i18n/react'
import { BeamAvatar } from './BeamAvatar.tsx'
export interface UserAvatarProps {
@@ -51,6 +52,7 @@ export function UserAvatar({
colors,
className,
}: UserAvatarProps) {
const { t } = useI18n()
const [photoFailed, setPhotoFailed] = useState(false)
const [gravatarFailed, setGravatarFailed] = useState(false)
const gravatarUrl = useGravatarUrl(email, size * 2)
@@ -73,7 +75,7 @@ export function UserAvatar({
return (
<img
src={src}
alt={name || email || 'avatar'}
alt={name || email || t('avatar')}
className={className}
style={style}
onError={() => setPhotoFailed(true)}
@@ -86,7 +88,7 @@ export function UserAvatar({
return (
<img
src={gravatarUrl}
alt={name || email || 'avatar'}
alt={name || email || t('avatar')}
className={className}
style={style}
onError={() => setGravatarFailed(true)}
+16 -14
View File
@@ -4,10 +4,11 @@
* UserOrgDropdown — the account control at the right edge of OrgHeader:
* who you are, which org you are acting as, and the way out.
*
* Self-contained by design: inline styles + theme.ts tokens, React the only
* runtime dependency, ZERO CSS-framework coupling.
* Self-contained by design: inline styles + theme.ts tokens, React and
* @hanzo/i18n the only runtime dependencies, ZERO CSS-framework coupling.
*/
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { useI18n } from '@hanzo/i18n/react'
import type { HanzoUser, HanzoOrg, OrgPage, OrgQuery } from './types.ts'
import { ORG_DOMAINS } from './types.ts'
import { U } from './hanzo-registry.ts'
@@ -99,6 +100,7 @@ export function UserOrgDropdown({
onMasqueradeStop,
}: UserOrgDropdownProps) {
useShellStyles()
const { t } = useI18n()
// Below 640px the trigger is the avatar alone; the name/email block and its
// chevron would crowd a phone header. A media query, not an inline style,
// because that is the one thing `style` cannot express. Stated as max-width
@@ -210,7 +212,7 @@ export function UserOrgDropdown({
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label="Account"
aria-label={t('Account')}
style={{ ...control(open), gap: 8, padding: '0 8px' }}
{...controlHover(open)}
>
@@ -243,7 +245,7 @@ export function UserOrgDropdown({
{open && (
<div
role="menu"
aria-label="Account"
aria-label={t('Account')}
style={{
position: 'absolute',
insetInlineEnd: 0,
@@ -257,7 +259,7 @@ export function UserOrgDropdown({
>
{/* Who you are */}
<div style={{ ...SECTION, padding: '12px 14px' }}>
<p style={{ ...LABEL, margin: 0 }}>{user.name || 'User'}</p>
<p style={{ ...LABEL, margin: 0 }}>{user.name || t('User')}</p>
<p style={{ margin: '2px 0 0', fontSize: FS.xs, color: CHROME.fgDim }}>
{user.email}
</p>
@@ -281,8 +283,8 @@ export function UserOrgDropdown({
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Find an organization"
aria-label="Find an organization"
placeholder={t('Find an organization')}
aria-label={t('Find an organization')}
style={{
width: '100%',
height: CTRL_H,
@@ -307,7 +309,7 @@ export function UserOrgDropdown({
{mine.length > 0 && (
<>
<p style={{ ...LABEL, margin: 0, padding: '2px 8px 4px' }}>
Organizations
{t('Organizations')}
</p>
{mine.map((org) => (
<OrgRow
@@ -334,14 +336,14 @@ export function UserOrgDropdown({
padding: mine.length ? '10px 8px 4px' : '2px 8px 4px',
}}
>
All organizations
{t('All organizations')}
</p>
{reach.map((org) => (
<OrgRow
key={org.id}
org={org}
current={false}
hint="Act as"
hint={t('Act as')}
onPick={() => {
onMasquerade?.(org.id)
setOpen(false)
@@ -360,7 +362,7 @@ export function UserOrgDropdown({
color: CHROME.fgDim,
}}
>
Searching…
{t('Searching…')}
</p>
)}
</div>
@@ -375,7 +377,7 @@ export function UserOrgDropdown({
style={link}
{...ghostHover()}
>
Account settings
{t('Account settings')}
</a>
<a
role="menuitem"
@@ -384,7 +386,7 @@ export function UserOrgDropdown({
style={link}
{...ghostHover()}
>
Billing
{t('Billing')}
</a>
<button
type="button"
@@ -396,7 +398,7 @@ export function UserOrgDropdown({
style={{ ...link, color: CHROME.fgDim }}
{...ghostHover(false, CHROME.fgDim)}
>
Sign out
{t('Sign out')}
</button>
</div>
</div>
+15 -5
View File
@@ -26,6 +26,8 @@
* leaves no room for the pretence.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { CHROME, FG_ON, FS, LABEL, PANEL, R, SCRIM, TAP_H, Z, row } from './theme.ts'
import { useMediaQuery } from './useMediaQuery.ts'
@@ -80,8 +82,13 @@ export function useCommandKey(toggle: () => void): void {
*
* `onAsk` is the host taking the question instead: a surface that IS the AI
* answers in place rather than opening a second copy of itself.
*
* The sentence and its value, not the finished string: a palette draws it as
* `t(...askLabel(question))`, so the words stay here and the language is the
* reader's.
*/
export const askLabel = (question: string) => `Ask AI: ${question}`
export const askLabel = (question: string) =>
[phrase('Ask AI: {question}'), { question }] as const
export function useAsk({
askHref,
@@ -395,12 +402,13 @@ export function PaletteList({
id?: string
children: React.ReactNode
}) {
const { t } = useI18n()
return (
<div
ref={listRef}
id={id}
role="listbox"
aria-label="Results"
aria-label={t('Results')}
style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '4px 0 6px' }}
>
{children}
@@ -602,6 +610,7 @@ function Lit({ text, hits }: { text: string; hits?: number[] }) {
/** The one calm line an empty result set gets. */
export function PaletteEmpty({ query }: { query: string }) {
const { t } = useI18n()
return (
<p
style={{
@@ -612,24 +621,25 @@ export function PaletteEmpty({ query }: { query: string }) {
color: CHROME.fgDim,
}}
>
No results for “{query.trim()}”.
{t('No results for “{query}”.', { query: query.trim() })}
</p>
)
}
/** The ↑↓ / ↵ / ⌘K hint bar every palette closes with. */
export function PaletteHints() {
const { t } = useI18n()
return (
<PaletteBar edge="bottom">
<span style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<kbd style={KBD}>↑</kbd>
<kbd style={KBD}>↓</kbd>
navigate
{t('navigate')}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<kbd style={KBD}>↵</kbd>
select
{t('select')}
</span>
</span>
<kbd style={KBD}>⌘K</kbd>
+4 -3
View File
@@ -10,9 +10,10 @@
* was a shape in one menu and bare text in the next.
*
* Inline paths, no icon package: the shell's contract is inline styles + theme
* tokens with React as the only runtime dep, and it must drop into a Next, Vite
* or vanilla host with zero setup. One `svg()` helper, one stroke weight, one
* 24-box, so a mark reads the same at 16px in a row and at 20px in a tile.
* tokens with React and @hanzo/i18n as the only runtime deps, and it must drop
* into a Next, Vite or vanilla host with zero setup. One `svg()` helper, one
* stroke weight, one 24-box, so a mark reads the same at 16px in a row and at
* 20px in a tile.
*
* A row NAMES its mark (`HanzoLink.glyph`); it never carries an element. The
* registry is data — a `.ts` module cannot hold a `.tsx` value — and a host that
+31 -30
View File
@@ -13,6 +13,7 @@
* primary cross-app surfaces.
*/
import React from 'react'
import { phrase } from '@hanzo/i18n'
import { MARKS } from './glyph.tsx'
import { U } from './hanzo-registry.ts'
@@ -64,7 +65,7 @@ export const HANZO_APPS: HanzoApp[] = [
id: 'chat',
label: 'Chat',
href: U.chat,
description: 'Use AI — ask anything',
description: phrase('Use AI — ask anything'),
icon: MARKS.chat,
category: 'Products',
core: true,
@@ -73,7 +74,7 @@ export const HANZO_APPS: HanzoApp[] = [
id: 'app',
label: 'App',
href: U.app,
description: 'Build and ship apps',
description: phrase('Build and ship apps'),
icon: MARKS.blocks,
category: 'Products',
core: true,
@@ -82,7 +83,7 @@ export const HANZO_APPS: HanzoApp[] = [
id: 'team',
label: 'Team',
href: U.team,
description: 'People and AI together',
description: phrase('People and AI together'),
icon: MARKS.users,
category: 'Products',
core: true,
@@ -91,7 +92,7 @@ export const HANZO_APPS: HanzoApp[] = [
id: 'studio',
label: 'Studio',
href: U.studio,
description: 'Models, prompts and agents',
description: phrase('Models, prompts and agents'),
icon: MARKS.studio,
category: 'Products',
core: true,
@@ -100,7 +101,7 @@ export const HANZO_APPS: HanzoApp[] = [
id: 'bot',
label: 'Bot',
href: U.bot,
description: 'Publish AI anywhere',
description: phrase('Publish AI anywhere'),
icon: MARKS.bot,
category: 'Products',
core: true,
@@ -109,7 +110,7 @@ export const HANZO_APPS: HanzoApp[] = [
id: 'cloud',
label: 'Cloud',
href: U.cloud,
description: 'Operate the platform',
description: phrase('Operate the platform'),
icon: MARKS.cloud,
category: 'Products',
core: true,
@@ -118,7 +119,7 @@ export const HANZO_APPS: HanzoApp[] = [
id: 'dev',
label: 'Dev',
href: U.dev,
description: 'Build from editor and terminal',
description: phrase('Build from editor and terminal'),
icon: MARKS.code,
category: 'Products',
},
@@ -126,15 +127,15 @@ export const HANZO_APPS: HanzoApp[] = [
id: 'world',
label: 'World',
href: U.ai + '/world',
description: 'Real-time global intelligence',
description: phrase('Real-time global intelligence'),
icon: MARKS.globe,
category: 'Products',
},
{
id: 'search',
label: 'Search',
label: phrase('Search'),
href: U.ai + '/search',
description: 'AI-powered search',
description: phrase('AI-powered search'),
icon: MARKS.search,
category: 'Products',
},
@@ -144,24 +145,24 @@ export const HANZO_APPS: HanzoApp[] = [
id: 'console',
label: 'Console',
href: U.console,
description: 'API keys, projects & products',
description: phrase('API keys, projects & products'),
icon: MARKS.terminal,
category: 'Platform',
core: true,
},
{
id: 'gateway',
label: 'Gateway',
label: phrase('Gateway'),
href: U.gateway,
description: 'Unified AI API gateway',
description: phrase('Unified AI API gateway'),
icon: MARKS.gateway,
category: 'Platform',
},
{
id: 'platform',
label: 'Platform',
label: phrase('Platform'),
href: U.platform,
description: 'Deploy & scale services',
description: phrase('Deploy & scale services'),
icon: MARKS.layers,
category: 'Platform',
},
@@ -171,23 +172,23 @@ export const HANZO_APPS: HanzoApp[] = [
id: 'base',
label: 'Base',
href: U.base,
description: 'Data, auth and files',
description: phrase('Data, auth and files'),
icon: MARKS.base,
category: 'Products',
},
{
id: 'desktop',
label: 'Desktop',
label: phrase('Desktop'),
href: U.desktop,
description: 'Desktop app',
description: phrase('Desktop app'),
icon: MARKS.monitor,
category: 'Install',
},
{
id: 'extension',
label: 'Browser',
label: phrase('Browser'),
href: U.extension,
description: 'Browser extension',
description: phrase('Browser extension'),
icon: MARKS.puzzle,
category: 'Install',
},
@@ -195,7 +196,7 @@ export const HANZO_APPS: HanzoApp[] = [
id: 'cli',
label: 'CLI',
href: U.cli,
description: 'Command-line interface',
description: phrase('Command-line interface'),
icon: MARKS.terminal,
category: 'Install',
},
@@ -203,15 +204,15 @@ export const HANZO_APPS: HanzoApp[] = [
id: 'sdks',
label: 'SDKs',
href: U.sdks,
description: 'Client libraries',
description: phrase('Client libraries'),
icon: MARKS.package,
category: 'Install',
},
{
id: 'downloads',
label: 'Downloads',
label: phrase('Downloads'),
href: U.downloads,
description: 'Every app and client',
description: phrase('Every app and client'),
icon: MARKS.download,
category: 'Install',
},
@@ -219,25 +220,25 @@ export const HANZO_APPS: HanzoApp[] = [
// ── Account ──
{
id: 'account',
label: 'Account',
label: phrase('Account'),
href: U.account,
description: 'Profile, orgs & billing',
description: phrase('Profile, orgs & billing'),
icon: MARKS.user,
category: 'Account',
},
{
id: 'billing',
label: 'Billing',
label: phrase('Billing'),
href: U.billing,
description: 'Subscriptions & usage',
description: phrase('Subscriptions & usage'),
icon: MARKS.card,
category: 'Account',
},
{
id: 'admin',
label: 'Admin',
label: phrase('Admin'),
href: U.admin,
description: 'Platform administration',
description: phrase('Platform administration'),
icon: MARKS.shield,
category: 'Account',
},
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -12,6 +12,8 @@
* A mark either carries the menu or it does not; that is a prop, not a fork.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { CHROME, FS, LABEL, PANEL, R, Z, ghostHover, row } from './theme.ts'
import { useShellStyles } from './shellStyles.ts'
@@ -188,14 +190,15 @@ const MENU_ITEM: React.CSSProperties = {
}
const BRAND_LINKS = [
{ label: 'Brand Guidelines', href: 'https://hanzo.ai/brand' },
{ label: 'Press Kit', href: 'https://hanzo.ai/press' },
{ label: 'Download Logo', href: 'https://hanzo.ai/brand#download' },
{ label: phrase('Brand Guidelines'), href: 'https://hanzo.ai/brand' },
{ label: phrase('Press Kit'), href: 'https://hanzo.ai/press' },
{ label: phrase('Download Logo'), href: 'https://hanzo.ai/brand#download' },
{ label: 'hanzo.ai →', href: 'https://hanzo.ai' },
]
function BrandMenu({ x, y, onClose }: { x: number; y: number; onClose: () => void }) {
useShellStyles()
const { t } = useI18n()
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -217,7 +220,7 @@ function BrandMenu({ x, y, onClose }: { x: number; y: number; onClose: () => voi
<div
ref={ref}
role="menu"
aria-label="Hanzo brand"
aria-label={t('Hanzo brand')}
data-hanzo-shell=""
style={{
position: 'fixed',
@@ -237,7 +240,7 @@ function BrandMenu({ x, y, onClose }: { x: number; y: number; onClose: () => voi
marginBottom: 4,
}}
>
<p style={{ ...LABEL, margin: 0 }}>Hanzo brand</p>
<p style={{ ...LABEL, margin: 0 }}>{t('Hanzo brand')}</p>
</div>
{BRAND_LINKS.map((item) => (
<a
@@ -250,7 +253,7 @@ function BrandMenu({ x, y, onClose }: { x: number; y: number; onClose: () => voi
style={MENU_ITEM}
{...ghostHover()}
>
{item.label}
{t(item.label)}
</a>
))}
<button
@@ -263,7 +266,7 @@ function BrandMenu({ x, y, onClose }: { x: number; y: number; onClose: () => voi
style={MENU_ITEM}
{...ghostHover()}
>
Copy SVG
{t('Copy SVG')}
</button>
</div>
)
+29 -4
View File
@@ -18,10 +18,29 @@
* carries its usual rows, which is what it did before this existed.
*/
import React from 'react'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { CHROME, FS, R } from './theme.ts'
import type { Plan } from './usePlan.ts'
import { resetsAt } from './usage.ts'
/**
* One sentence per span, because the span is a noun inside the sentence and a
* language inflects it there. A Map, since the span is the server's word.
*/
const REACHED = new Map([
['hour', phrase('hour limit reached')],
['day', phrase('day limit reached')],
['week', phrase('week limit reached')],
['month', phrase('month limit reached')],
])
const LEFT = new Map([
['hour', phrase('{n} left this hour')],
['day', phrase('{n} left this day')],
['week', phrase('{n} left this week')],
['month', phrase('{n} left this month')],
])
export interface MeterProps {
/** The resolved plan, from `usePlan()`. */
plan: Plan
@@ -30,6 +49,7 @@ export interface MeterProps {
}
export function Meter({ plan, href }: MeterProps) {
const { t } = useI18n()
// Nothing to say yet, or nothing to say at all. A skeleton here would flash on
// every menu open for a reading most viewers do not have.
if (plan.loading || plan.state !== 'plan') return null
@@ -80,8 +100,13 @@ export function Meter({ plan, href }: MeterProps) {
person can act on; "8% left" needs arithmetic they do not have
the denominator for. The bar already carries the proportion. */}
{usage.over
? `${usage.span} limit reached`
: `${usage.remaining.toLocaleString()} left this ${usage.span}`}
? t(REACHED.get(usage.span) ?? phrase('{span} limit reached'), {
span: usage.span,
})
: t(LEFT.get(usage.span) ?? phrase('{n} left this {span}'), {
n: usage.remaining.toLocaleString(),
span: usage.span,
})}
</span>
) : null}
</div>
@@ -92,7 +117,7 @@ export function Meter({ plan, href }: MeterProps) {
aria-valuenow={usage.usedPct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${plan.name} usage this period`}
aria-label={t('{plan} usage this period', { plan: plan.name })}
style={{
height: 3,
borderRadius: R.pill,
@@ -118,7 +143,7 @@ export function Meter({ plan, href }: MeterProps) {
{/* Shown only past halfway. Before that the bound is not the reader's
problem and the line is noise; after it, "clears at 11:00 PM" is
the whole answer to "what do I do now". */}
Clears {resetsAt(usage)}
{t('Clears {time}', { time: resetsAt(usage) })}
</div>
) : null}
</div>