Files
Cursor Agentandz 5561f61683 site: glass at rest, and counts from this build's snapshots
The marketing bar is glass before the page scrolls. The chrome, palette, and homepage gates follow the new nav. The model and repository snapshots this build read are 561 and 825.

Co-authored-by: z <[email protected]>
2026-10-26 22:10:56 +00:00

990 lines
36 KiB
TypeScript

'use client'
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'
import { Anchor, Text, View, XStack, YStack, type AnchorProps, type GuiElement } from '@hanzo/gui'
import {
HanzoWordmark,
MARKS,
type GlyphName,
type HanzoCommandEntry,
} from '@hanzogui/shell'
import { HANZO_FOOTER_BOTTOM } from '@hanzogui/shell/registry'
import { usePathname } from 'next/navigation'
import { bare, betaRunning, subscribeBeta, useBack } from '@/components/shell/mode'
import { AccountAdminWidget } from '@/components/shell/account-widget'
import { Ask } from '@/components/shell/composer'
import { HEADER, ROOF, header } from '@/components/shell/dock'
import { Palette } from '@/components/shell/later'
import { Footer, type FooterColumn, type FooterLink } from '@hanzo/ui/marketing'
import { useIam, useIamIdentity } from '@hanzo/iam/react'
import { useSignOut } from '@hanzo/app/lib/signout'
import { bearer, hasSession, subject } from '@hanzo/app/lib/session'
import { apex, room } from '@hanzo/app/lib/host'
import { useHost } from '@hanzo/app/lib/hostname'
import { useHydrated } from '@hanzo/app/lib/hydrated'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import pages from '@/lib/data/pages.json'
import { WindowControls, dragWindow, toggleWindowMaximize } from '@/components/shell/WindowControls'
import { DOCS, FOOT, FOOT_LINE, GET_KEY, LOGIN, MENUS, OPEN, away, goToChat, type Link, type Menu } from './nav-data'
function useScrolled(threshold = 8) {
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const checkScroll = () => {
const y = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0
setScrolled(y > threshold)
}
checkScroll()
window.addEventListener('scroll', checkScroll, { passive: true })
return () => window.removeEventListener('scroll', checkScroll)
}, [threshold])
return scrolled
}
/**
* The chrome: one nav tree (`nav-data.ts`) for the bar and the phone sheet.
* The footer is the short set (`FOOT`), so a reader finds the rest on the
* page a column title opens.
*
* The bar is `@hanzogui/shell`'s vocabulary — its marks, its wordmark, its ⌘K
* palette and its account menu — laid out the way hanzo.ai reads: the wordmark
* goes home, six doors follow with a mark before each label, search closes the
* row, and Log in and Get API Key stand at the far end. The shell's
* `HanzoHeader` has no search in the row, so the layout is drawn here and the
* parts are the shell's.
*
* Every style is a gui prop on the element that wears it; no stylesheet rule
* reaches the bar, a plane, the log-in card or the sheet. The geometry is the
* shell's — a 60px row, 34px pills, 13px labels, a 16px mark before each, a
* 12px chevron after — and every ink and ground is a theme token, so the light
* theme needs no second copy. Below gui's `$lg` (1024) the bar is the name,
* search and a menu button; the marks before the labels wait for `$xl`
* (1280), because between the two the row fits its labels and not its marks.
*
* TWO GROUNDS for the bar. Glass on every page, at rest and scrolled: the
* page reads through it, frosted, and a first screen whose ground is a picture
* or a glow starts at the top of the window, behind it (`under` in
* components/shell/dock.ts), so the bar is never a black band over the top of
* a page. And the plane's own opaque ground while a menu or the sheet hangs
* from it, so bar and plane are one surface. The open bar carries no backdrop
* filter on purpose: a filtered element is a backdrop root, and the veil inside
* it would then blur the bar instead of the page.
*
* EVERY WORD IS THE READER'S. The tree's labels, titles and hints are marked in
* nav-data.ts and drawn here through `t` (@hanzo/i18n); this file's own words —
* Log in, the menu buttons, every `aria-label` — are `t` calls. Each side is a
* logical prop (`marginInlineStart`, `insetInlineEnd`, `borderStartEndRadius`),
* so under `dir="rtl"` the bar reads from the right. What a stylesheet or a gate
* selects is a `data-slot` (`home`, `menus`, `search`), never a label, which is
* in the reader's language.
*/
/** Every page the site publishes, for ⌘K. Written at prebuild by scripts/sync-pages.mjs. */
const PAGES: HanzoCommandEntry[] = pages.map((page) => ({ ...page, group: phrase('Pages') }))
/**
* The nav's rows that leave the site: ⌘K finds them too, since no page lists
* them. The docs' own sections stay out — Documentation finds the docs, and ten
* rows that all match "docs" by address would bury it.
*/
const OUT: HanzoCommandEntry[] = MENUS
.flatMap((menu) => {
const rows = menu.groups.flatMap((group) => group.links.map((link) => ({ menu, link })))
// A door that leaves the site (Docs) is itself a command. A docs section is not.
return away(menu.href) ? [{ menu, link: { label: menu.label, href: menu.href } }, ...rows] : rows
})
.filter(({ link }) => away(link.href) && !link.href.startsWith(`${DOCS}/`))
.map(({ menu, link }) => ({ id: `nav/${menu.id}/${link.label}`, title: link.label, href: link.href, group: menu.label, external: true }))
/** A menu stays open this long after the pointer leaves, so a diagonal path to a row keeps it. */
const GRACE = 160
/** And opens after the pointer rests this long, so crossing the bar opens nothing. */
const REST = 70
/** The bar's two inks over the page: a wash for a lit pill, and its hairline. */
const WASH = '$ink/6'
const RING = 'inset 0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent)'
/** A pill lit: under the pointer, or holding its menu open. */
const LIT = { color: 'var(--foreground)', bg: WASH, boxShadow: RING } as const
/** Where what hangs from the bar begins: under the bar and the notch above it. */
const UNDER = ROOF
/** The open bar, the planes, the log-in card and the sheet stand on one opaque ground. */
const PANEL = 'var(--chrome-panel)'
/** The drop under what hangs from the bar. */
const DROP = '0 24px 60px -16px color-mix(in srgb, var(--pure-black) 75%, transparent)'
/** The bar's glass: the page under it, frosted and dimmed so the bar's words read on any picture. */
const GLASS = '$background/72'
const BLUR = 'blur(20px) saturate(1.8)'
/** The highest z-index there is. The search palette stands in a fixed layer at
* it, so nothing fixed to the window, the cookie sheet among them, draws over
* an open palette. */
const TOP = 2147483647
/**
* The shell's account menu paints with `var(--white-92…)`, and @hanzo/design
* keeps those rungs white in both themes, because this page spends them on
* glows that stay white on a light page. The bar follows the theme, so inside
* it the four flip to black: `light-dark()` reads the `color-scheme` @hanzo/design
* sets with the theme, so the dark theme keeps design's own values and the light
* one gets black. The dimmest is PUSHED rather than mirrored — black on white
* reads fainter than white on black at one alpha: .45 black on white is 3.35:1,
* .56 is 4.5:1 on the bar and on the panel. The hairline is the theme's own
* `--border`, which is that rung in either theme.
*/
const ink = (dark: number, light: number) =>
`light-dark(color-mix(in srgb, var(--pure-black) ${light}%, transparent), color-mix(in srgb, var(--pure-white) ${dark}%, transparent))`
const INK = {
'--white-92': ink(92, 92),
'--white-60': ink(60, 60),
'--white-45': ink(45, 56),
'--white-10': 'var(--border)',
} as CSSProperties
/** The bar: a sticky row under the notch, on the page's gutter. It publishes its
* height as `--header` (components/shell/dock.ts), which @hanzo/design's scroll
* padding reads. */
const BAR = {
position: 'sticky',
t: 0,
z: 'var(--z-header)',
items: 'center',
gap: 12,
boxSizing: 'content-box',
height: HEADER,
pt: 'calc(0px + env(safe-area-inset-top))',
pb: 0,
px: 'var(--page-gutter)',
} as const
/** The name of the place, lit under the pointer and while the company menu is open. */
const BRAND = { items: 'center', shrink: 0, height: 34, marginInlineStart: -12, rounded: 999, hoverStyle: { bg: WASH, boxShadow: RING } } as const
/** The wordmark: home, and never dim. It owns the pill's end edge only where the company chevron is gone. */
const NAME = { display: 'flex', items: 'center', height: '100%', paddingInline: 12, color: 'var(--foreground)', whiteSpace: 'nowrap', textDecorationLine: 'none' } as const
/** A pill in the bar: dim at rest, lit under the pointer. A button's own padding is the browser's, so it is stated. */
const PILL = {
display: 'flex',
items: 'center',
shrink: 0,
height: 34,
p: 0,
borderWidth: 0,
rounded: 999,
bg: 'transparent',
color: 'var(--muted-foreground)',
cursor: 'pointer',
hoverStyle: LIT,
} as const
/** A pill that opens a menu: its label, then a chevron. */
const TRIGGER = { ...PILL, gap: 6, px: 12, fontSize: '$2', lineHeight: 20, fontWeight: '500', whiteSpace: 'nowrap' } as const
/** The one filled control, in the bar and in the sheet. */
const ACT = {
display: 'flex',
items: 'center',
justify: 'center',
gap: 2,
height: 34,
px: 14,
rounded: 999,
bg: 'var(--foreground)',
color: 'var(--background)',
fontSize: '$2',
lineHeight: 20,
fontWeight: '600',
whiteSpace: 'nowrap',
hoverStyle: { opacity: 0.88 },
} as const
/** What hangs from the bar arrives: up 4px, faded in. */
const ARRIVE = { enterStyle: { opacity: 0, y: -4 }, transition: 'quickest' } as const
/**
* A row in a plane. As wide as its words, so a keyboard ring hugs the words it
* names rather than the column; its padding steps out of the column, so the
* words stand on the column's edge.
*/
const ROW = {
display: 'flex',
items: 'center',
self: 'flex-start',
mx: -8,
px: 8,
rounded: 8,
whiteSpace: 'nowrap',
hoverStyle: { color: 'var(--foreground)' },
focusVisibleStyle: { color: 'var(--foreground)' },
} as const
/** The destinations someone arrives wanting, in display type. */
const LEAD = { ...ROW, py: 2, fontSize: '$8', lineHeight: 33.28, fontWeight: '400', letterSpacing: -0.26, color: 'var(--foreground)' } as const
const PLAIN = { ...ROW, py: 5, fontSize: '$2', lineHeight: 20, fontWeight: '500', color: 'var(--muted-foreground)' } as const
/** A row in the sheet: a thumb's height, the column's width. */
const LINE = {
display: 'flex',
items: 'center',
justify: 'flex-start',
minH: 44,
rounded: 8,
fontWeight: '400',
color: 'var(--muted-foreground)',
hoverStyle: { color: 'var(--foreground)' },
focusVisibleStyle: { color: 'var(--foreground)' },
} as const
/** The sheet's doors: Log in's rows, or Account. */
const DOOR = { display: 'flex', items: 'center', justify: 'flex-start', minH: 44, fontSize: '$4', lineHeight: 20, fontWeight: '400', color: 'var(--foreground)' } as const
/** A menu's mark. In the bar it waits for `$xl`; in the sheet it always shows. */
function Mark({ name, bar = false }: { name: GlyphName; bar?: boolean }) {
const Glyph = MARKS[name]
return (
<View render="span" data-slot="mark" aria-hidden display={bar ? 'none' : 'flex'} $xl={bar ? { display: 'flex' } : undefined} opacity={0.9}>
<Glyph size={16} />
</View>
)
}
/** ▾, turned over while what it opens is open. */
function Chevron({ turned, end = false }: { turned: boolean; end?: boolean }) {
return (
<View render="span" aria-hidden display="flex" shrink={0} marginInlineStart={end ? 'auto' : 0} rotate={turned ? '180deg' : '0deg'} transition="quickest">
<svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</View>
)
}
/** ↗ — the shell's outbound arrow, riding the label's size and ink. It points
* up and along the line, so a right-to-left page turns it (`data-i18n-mirror`). */
function Out() {
return (
<View render="span" aria-hidden display="flex" marginInlineStart="calc(0.28em)" opacity={0.7}>
<svg data-i18n-mirror width="0.75em" height="0.75em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</View>
)
}
/** A row: the label, and ↗ with a new tab when it leaves the site. */
function Row({ link, onClick, ...style }: { link: Link; onClick?: (e: { preventDefault: () => void }) => void } & Omit<AnchorProps, 'href' | 'target' | 'rel' | 'onClick'>) {
const { t } = useI18n()
const out = away(link.href)
return (
<Anchor
href={link.href}
target={out ? '_blank' : undefined}
rel={out ? 'noreferrer' : undefined}
onClick={onClick}
textDecorationLine="none"
{...style}
>
{t(link.label)}
{out ? <Out /> : null}
</Anchor>
)
}
/** A column's title. In the sheet it is the quiet label over a section's rows. */
function Title({ sheet = false, children }: { sheet?: boolean; children: ReactNode }) {
return (
<Text
render="p"
mt={sheet ? 4 : 0}
mb={sheet ? 2 : 10}
mx={0}
fontSize={sheet ? '$1' : '$2'}
lineHeight={20}
fontWeight="600"
color={sheet ? 'var(--text-tertiary)' : 'var(--foreground)'}
>
{children}
</Text>
)
}
/**
* A menu's columns, laid out from the wordmark's column: the lead column in
* display type, the rest as lists. The column under the pointer is the one
* being read; the others step back to 45%.
*/
function Columns({ menu, onPick }: { menu: Menu; onPick: () => void }) {
const { t } = useI18n()
const [hot, setHot] = useState<number | null>(null)
return (
<View
display="grid"
gridAutoFlow="column"
gridAutoColumns="max-content"
justify="flex-start"
gap="max(24px, min(3vw, 64px))"
pt={36}
pb={48}
px="calc(var(--page-gutter) + 8px)"
>
{menu.groups.map((group, i) => (
<YStack
key={group.title}
gap={2}
minW={0}
opacity={hot === null || hot === i ? 1 : 0.45}
transition="quickest"
onPointerEnter={() => setHot(i)}
onPointerLeave={() => setHot(null)}
>
<Title>{t(group.title)}</Title>
{group.links.map((link) => (
<Row key={link.label} link={link} onClick={onPick} {...(group.lead ? LEAD : PLAIN)} />
))}
</YStack>
))}
</View>
)
}
/**
* Who is reading: signed in or not, and as whom.
*
* The export is built with no session, so the first paint is signed-out and the
* render after hydration reads storage. `useIamIdentity` names the person and
* finds the picture; the token's claims answer when it has not resolved yet, so
* a signed-in reader never sees Log in.
*/
function useReader() {
const { isLoading, user, isAuthenticated } = useIam()
const logout = useSignOut()
const identity = useIamIdentity()
const hydrated = useHydrated()
const { t } = useI18n()
const signedIn = isAuthenticated || Boolean(user) || (hydrated && hasSession())
const person = useMemo(() => {
if (!signedIn) return null
if (identity) return { name: identity.name, email: identity.email ?? undefined, avatar: identity.avatarUrl ?? undefined }
let claims: Record<string, unknown> = {}
const part = bearer()?.split('.')[1]
if (part) {
try {
claims = JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/')))
} catch {
claims = {}
}
}
const text = (v: unknown) => (typeof v === 'string' && v ? v : undefined)
const email = text(user?.email) ?? text(claims.email)
const sub = subject()
const name =
text(user?.displayName) ??
text(user?.name) ??
text(claims.name) ??
text(claims.preferred_username) ??
(email ? email.split('@')[0] : sub?.split('/').pop() ?? t('You'))
return { name, email, avatar: text(claims.picture) }
}, [signedIn, identity, user, t])
return { ready: !isLoading, person, logout }
}
/** The DOM node a gui element stands for, on the web. */
const node = (el: GuiElement | null) => (el instanceof HTMLElement ? el : null)
/** What the bar reads off a pointer and a key: gui hands the web's own events through. */
type Hover = { type: string; pointerType?: string }
type Key = { key?: string; preventDefault: () => void }
/** Menus step aside on the way in, on the way to pay, and once a beta track is chosen. */
function useSlim() {
const path = usePathname() ?? '/'
const applying = useSyncExternalStore(subscribeBeta, betaRunning, () => false)
return bare(path) || applying
}
/**
* @hanzo/app' `Chrome`, fetched by the pages that draw it. Its account menu
* and avatar are some 30 KB of script (gui's menu, roving focus, the avatar's
* hue), and imported here they were in the first load of every page while only
* the focused ones draw it.
*
* NO SUSPENSE BOUNDARY AROUND IT, on purpose. A focused page prerenders the bar
* in place and hydrates once its chunk (a preload in that page's head) has
* arrived, so the bar is never anything but the bar. Behind a boundary the
* prerender flushed a placeholder and swapped the bar in further down the
* document, and hydration then threw the bar away for the placeholder again:
* IAM settling is an update, and a boundary updated before it has hydrated is
* drawn anew on the client, from its fallback, until the chunk lands.
*/
/**
* The bar over a focused page: the way in, the way to pay, the organization
* chooser, a beta application. It is @hanzo/app' `Chrome`, the same bar every
* focused page of every host draws; this page only hands it the step back that the
* page asked for (`setBack`) and publishes its height as `--header`.
*/
function Focused() {
const back = useBack()
const holder = useRef<GuiElement>(null)
const { person, logout } = useReader()
const { t } = useI18n()
const scrolled = useScrolled(8)
useLayoutEffect(() => header(node(holder.current)?.querySelector<HTMLElement>('header[data-slot="masthead"]') ?? null), [])
return (
<View ref={holder} display="contents">
<XStack
render="header"
data-slot="masthead"
data-tauri-drag-region="deep"
data-scrolled={scrolled ? 'true' : 'false'}
onMouseDown={dragWindow}
onDoubleClick={toggleWindowMaximize}
position="sticky"
t={0}
z="var(--z-header)"
shrink={0}
boxSizing="content-box"
height={60}
$platform-web={{ paddingTop: 'env(safe-area-inset-top)' }}
px={20}
gap={12}
items="center"
bg={scrolled ? GLASS : 'transparent'}
backdropFilter={scrolled ? BLUR : 'none'}
transition="quickest"
borderBottomWidth={0}
>
<Text
render={
<a
href="/"
aria-label={t('Hanzo AI home')}
data-slot="home"
data-tauri-drag-region="deep"
onClick={(e) => {
if (typeof document !== 'undefined' && document.documentElement.getAttribute('data-desktop') === 'true') {
e.preventDefault()
}
}}
/>
}
display="flex"
items="center"
shrink={0}
color="var(--foreground)"
textDecorationLine="none"
data-tauri-drag-region="deep"
>
<HanzoWordmark label="Hanzo AI" size={22} data-tauri-drag-region="deep" />
</Text>
{back}
<View flex={1} data-tauri-drag-region="deep" />
{person ? <AccountAdminWidget person={person} onSignOut={logout} /> : null}
<WindowControls />
</XStack>
</View>
)
}
export function SiteHeader() {
const path = usePathname() ?? '/'
const slim = useSlim()
const onApex = apex(useHost())
const { ready, person, logout } = useReader()
const { t } = useI18n()
const scrolled = useScrolled(8)
const [open, setOpen] = useState<string | null>(null)
const [sheet, setSheet] = useState(false)
const [search, setSearch] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const pinned = useRef(false)
const bar = useRef<GuiElement>(null)
const toggle = useRef<GuiElement>(null)
/** The bar's element, held for the menus and measured for `--header`. */
const masthead = useCallback((el: GuiElement | null) => {
bar.current = el
return header(node(el))
}, [])
const close = useCallback(() => {
clearTimeout(timer.current)
pinned.current = false
setOpen(null)
}, [])
// Escape closes whatever is open and hands focus back to what opened it.
useEffect(() => {
if (!open && !sheet) return
const key = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
if (open) {
node(bar.current)?.querySelector<HTMLElement>(`[aria-controls="menu-${open}"]`)?.focus()
close()
}
if (sheet) {
setSheet(false)
node(toggle.current)?.focus()
}
}
document.addEventListener('keydown', key)
return () => document.removeEventListener('keydown', key)
}, [open, sheet, close])
// A press anywhere outside the bar closes an open menu.
useEffect(() => {
if (!open) return
const outside = (e: PointerEvent) => {
if (!node(bar.current)?.contains(e.target as Node)) close()
}
document.addEventListener('pointerdown', outside)
return () => document.removeEventListener('pointerdown', outside)
}, [open, close])
// The sheet holds the screen: the page under it does not scroll, and it goes
// away when the window grows into the bar.
useEffect(() => {
if (!sheet) return
const root = document.documentElement
root.style.overflow = 'hidden'
const wide = matchMedia('(min-width: 1024px)')
const grow = () => wide.matches && setSheet(false)
wide.addEventListener('change', grow)
return () => {
root.style.overflow = ''
wide.removeEventListener('change', grow)
}
}, [sheet])
const rest = (id: string) => (e: Hover) => {
if (e.pointerType !== 'mouse') return
clearTimeout(timer.current)
if (open === id) return
if (open) {
pinned.current = false
setOpen(id)
} else timer.current = setTimeout(() => setOpen(id), REST)
}
const leave = (e: Hover) => {
if (e.pointerType !== 'mouse' || pinned.current) return
clearTimeout(timer.current)
timer.current = setTimeout(() => setOpen(null), GRACE)
}
const press = (id: string) => () => {
clearTimeout(timer.current)
// A click on a menu the pointer already opened keeps it open; a second one closes it.
if (open === id && pinned.current) return close()
pinned.current = true
setOpen(id)
}
const step = (id: string) => (e: Key) => {
if (e.key !== 'ArrowDown') return
e.preventDefault()
pinned.current = true
setOpen(id)
requestAnimationFrame(() => document.querySelector<HTMLElement>(`#menu-${id} a`)?.focus())
}
// Focus that leaves a menu closes it.
const blur = (e: React.FocusEvent<HTMLElement>) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) close()
}
const find = () => {
close()
setSheet(false)
setSearch(true)
}
const commands = useMemo(
() => [
...(onApex ? PAGES.filter((p) => !room(p.href)) : PAGES).map((p) => ({ ...p, group: p.group && t(p.group) })),
...OUT.map((c) => ({ ...c, title: t(c.title), group: c.group && t(c.group) })),
],
[onApex, t],
)
/** What a pill wears while its menu is open. */
const lit = (id: string) => (open === id ? LIT : {})
const menu = (item: Menu, trigger: ReactNode) => (
<XStack position="relative" onPointerEnter={rest(item.id)} onPointerLeave={leave} onBlur={blur}>
{trigger}
{open === item.id ? (
/* A menu's panel spans the window under the bar. */
<YStack
id={`menu-${item.id}`}
role="group"
aria-label={t(item.label)}
position="fixed"
t={UNDER}
l={0}
r={0}
z="var(--z-raised)"
maxH={`calc(100dvh - ${HEADER}px)`}
overflowY="auto"
bg={PANEL}
boxShadow={DROP}
{...ARRIVE}
>
<Columns menu={item} onPick={close} />
</YStack>
) : null}
</XStack>
)
const trigger = (item: Menu) => (
<Text
render="button"
{...TRIGGER}
{...lit(item.id)}
aria-expanded={open === item.id}
aria-controls={`menu-${item.id}`}
onClick={press(item.id)}
onKeyDown={step(item.id)}
>
<Mark name={item.glyph} bar />
{t(item.label)}
<Chevron turned={open === item.id} />
</Text>
)
const signedIn = ready && person
// /projects is its own workspace (with the app rail and focused shell), so it draws no marketing header.
if (path === '/projects' || path.startsWith('/projects/')) return null
if (slim) return <Focused key="bare" />
const lifted = open !== null || sheet
return (
<>
<XStack
key="full"
ref={masthead}
render="header"
data-slot="masthead"
data-tauri-drag-region="deep"
data-scrolled={scrolled ? 'true' : 'false'}
onMouseDown={dragWindow}
onDoubleClick={toggleWindowMaximize}
{...BAR}
bg={lifted ? PANEL : GLASS}
backdropFilter={lifted ? 'none' : BLUR}
transition="quickest"
borderBottomWidth={0}
// inline-style: custom properties @hanzogui/shell's account menu reads; no prop sets a variable.
style={INK}
>
<XStack render="span" {...BRAND}>
<Anchor
href="/"
aria-label={t('Hanzo AI home')}
data-slot="home"
data-tauri-drag-region="deep"
onClick={(e) => {
if (typeof document !== 'undefined' && document.documentElement.getAttribute('data-desktop') === 'true') {
e.preventDefault()
}
}}
{...NAME}
>
<HanzoWordmark label="Hanzo AI" size={22} data-tauri-drag-region="deep" />
</Anchor>
</XStack>
<XStack render="nav" aria-label={t('Main')} data-slot="menus" display="none" $lg={{ display: 'flex' }} items="center" gap={2}>
{MENUS.map((item) => (
<Fragment key={item.id}>
{item.groups.length ? (
menu(item, trigger(item))
) : (
<Anchor
href={item.href}
{...(away(item.href) ? { target: '_blank', rel: 'noreferrer' } : {})}
{...TRIGGER}
textDecorationLine="none"
>
<Mark name={item.glyph} bar />
{t(item.label)}
</Anchor>
)}
</Fragment>
))}
</XStack>
{/* The page recedes under an open plane, and a press on it closes the plane. */}
{open && open !== 'login' ? (
<View
aria-hidden
onPress={close}
position="fixed"
t={UNDER}
l={0}
r={0}
b={0}
z="var(--z-base)"
bg="$black/40"
backdropFilter="blur(24px) saturate(1.2)"
/>
) : null}
<Text
render="button"
aria-label={t('Search Hanzo or ask AI')}
data-slot="search"
onClick={find}
{...PILL}
justify="center"
width={44}
height={44}
marginInlineStart="auto"
$lg={{ width: 34, height: 34, marginInlineStart: 0 }}
>
<MARKS.search size={16} />
</Text>
<XStack items="center" gap={8} $lg={{ marginInlineStart: 'auto' }}>
{signedIn && person ? (
<AccountAdminWidget person={person} onSignOut={logout} />
) : (
<Anchor href={LOGIN.href} {...TRIGGER} display="none" $lg={{ display: 'flex' }} textDecorationLine="none">
{t(LOGIN.label)}
</Anchor>
)}
<Row link={signedIn ? OPEN : GET_KEY} {...ACT} display="none" $lg={{ display: 'flex' }} />
<Text
ref={toggle}
render="button"
aria-label={t(sheet ? 'Close menu' : 'Open menu')}
aria-expanded={sheet}
aria-controls="sheet"
onClick={() => {
close()
setSheet((s) => !s)
}}
display="grid"
$lg={{ display: 'none' }}
placeItems="center"
width={44}
height={44}
p={0}
marginInlineEnd={-10}
borderWidth={0}
bg="transparent"
color="var(--foreground)"
cursor="pointer"
>
<svg width={22} height={22} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true">
<path d={sheet ? 'M6 6l12 12M18 6L6 18' : 'M3 9h18M3 15h18'} />
</svg>
</Text>
</XStack>
<WindowControls />
</XStack>
{sheet ? <Sheet signedIn={Boolean(signedIn)} onClose={() => setSheet(false)} /> : null}
{/* A fixed box of no size is a stacking context at TOP, and the palette's
scrim and panel, fixed to the window themselves, stack inside it. */}
<View position="fixed" z={TOP}>
<Palette commands={commands} open={search} onOpenChange={setSearch} onAsk={goToChat} />
</View>
</>
)
}
/**
* The phone's menu: the whole tree, one section open at a time, then the two
* doors. A `<details>` per section, so the browser keeps which one is open and
* closes the rest (`name`); the chevron reads it back. It stands under the bar
* and carries the bottom inset, as the bar carries the top one.
*/
function Sheet({ signedIn, onClose }: { signedIn: boolean; onClose: () => void }) {
const { t } = useI18n()
const first = useRef<GuiElement>(null)
const [section, setSection] = useState<string | null>(null)
useEffect(() => node(first.current)?.focus(), [])
return (
<View
id="sheet"
role="dialog"
aria-modal
aria-label={t('Menu')}
display="block"
position="fixed"
t={UNDER}
l={0}
r={0}
b={0}
z="var(--z-overlay)"
overflowY="auto"
$platform-web={{ overscrollBehaviorX: 'contain', overscrollBehaviorY: 'contain' }}
pt={4}
px="var(--page-gutter)"
pb="calc(24px + env(safe-area-inset-bottom))"
bg={PANEL}
{...ARRIVE}
>
<View render="nav" aria-label={t('Main')} display="block">
{MENUS.map((item, i) =>
item.groups.length === 0 ? (
// An entry with no columns is a link to its page, a row as tall as a section's.
<Anchor
key={item.id}
href={item.href}
{...(away(item.href) ? { target: '_blank', rel: 'noreferrer' } : {})}
onClick={onClose}
display="flex"
items="center"
gap={10}
minH={56}
fontSize="$6"
lineHeight={20}
fontWeight="500"
color="var(--foreground)"
textDecorationLine="none"
borderBottomWidth={1}
borderColor="var(--border)"
>
<Mark name={item.glyph} />
{t(item.label)}
</Anchor>
) : (
<View
key={item.id}
render={
<details
name="sheet"
onToggle={(e) => {
const now = e.currentTarget.open
setSection((s) => (now ? item.id : s === item.id ? null : s))
}}
/>
}
display="block"
borderBottomWidth={1}
borderColor="var(--border)"
>
<Text
ref={i === 0 ? first : undefined}
render="summary"
display="flex"
items="center"
gap={10}
minH={56}
fontSize="$6"
lineHeight={20}
fontWeight="500"
color="var(--foreground)"
cursor="pointer"
>
<Mark name={item.glyph} />
{t(item.label)}
<Chevron turned={section === item.id} end />
</Text>
{item.groups.map((group) => (
<YStack key={group.title} gap={2} minW={0} pb={16}>
<Title sheet>{t(group.title)}</Title>
{group.links.map((link) => (
<Row
key={link.label}
link={link}
onClick={onClose}
{...LINE}
fontSize={group.lead ? '$6' : '$4'}
lineHeight={group.lead ? 20.4 : 18}
/>
))}
</YStack>
))}
</View>
),
)}
</View>
<View display="grid" gap={4} pt={20}>
{signedIn ? (
<Anchor href="/settings" textDecorationLine="none" {...DOOR}>
{t('Settings')}
</Anchor>
) : (
<Row link={LOGIN} onClick={onClose} {...DOOR} />
)}
<Row
link={signedIn ? OPEN : GET_KEY}
onClick={onClose}
{...ACT}
height={48}
mt={12}
fontSize="$3"
/>
</View>
</View>
)
}
/** A door in the footer, in the reader's words. */
const door = (link: Link, t: (source: string) => string): FooterLink => ({ label: t(link.label), href: link.href, out: away(link.href) })
/** The footer's doors, the header's menus and Company: each column a title that opens the subject, then its links. */
const columns = (t: (source: string) => string): FooterColumn[] =>
FOOT.map((item) => ({
id: item.id,
title: t(item.label),
href: item.href,
out: away(item.href),
links: item.groups.flatMap((group) => group.links).map((link) => door(link, t)),
}))
const FOOTER_SOCIALS = [
{ label: 'Discord', href: 'https://discord.gg/XthHQQj' },
{ label: 'X', href: 'https://x.com/hanzoai' },
{ label: 'GitHub', href: 'https://github.com/hanzoai' },
{ label: 'LinkedIn', href: 'https://linkedin.com/company/hanzo-ai' },
{ label: 'Facebook', href: 'https://facebook.com/hanzoinc' },
{ label: 'Bluesky', href: 'https://bsky.app/profile/hanzoai.bsky.social' },
{ label: 'Reddit', href: 'https://www.reddit.com/r/HanzoAI/' },
{ label: 'TikTok', href: 'https://www.tiktok.com/@hanzoinc' },
{ label: 'Instagram', href: 'https://www.instagram.com/hanzoinc' },
] as const
export function SiteFooter() {
const slim = useSlim()
const { t } = useI18n()
if (slim) return null
return (
<>
<Footer
gutter="var(--page-gutter)"
ask={<Ask />}
columns={columns(t)}
legal={FOOT_LINE.map((link) => door(link, t))}
copyright={HANZO_FOOTER_BOTTOM.copyright}
wordmark={
<Anchor href="https://hanzo.ai" aria-label={t('Hanzo AI home')} display="inline-flex" items="center" color="inherit" textDecorationLine="none">
<HanzoWordmark label="Hanzo AI" size={20} />
</Anchor>
}
/>
<XStack
render="nav"
aria-label={t('Hanzo Social Channels')}
data-slot="footer-socials"
width="100%"
flexWrap="wrap"
items="center"
gap={18}
rowGap={8}
pb={32}
px="var(--page-gutter)"
>
{FOOTER_SOCIALS.map((s) => (
<Anchor
key={s.label}
href={s.href}
target="_blank"
rel="noopener noreferrer"
color="var(--muted-foreground)"
fontSize="$2"
lineHeight={18}
textDecorationLine="none"
hoverStyle={{ color: 'var(--foreground)' }}
>
{s.label}
</Anchor>
))}
</XStack>
</>
)
}