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