mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-23 02:31:31 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
@@ -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. */}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
+423
-283
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user