Files
zeekay cb6db9e659 design: every page and component outside the landing and beta paths is at zero lint
Inline style objects became gui props (Box for a div, View or Text rendering the
element for the rest, since Box styles only a div), raw colours and sizes became
the design's tokens, the brand swatches read @hanzo/design's own values, and the
project and creation hues moved to palette.json, which is data. The newsletter's
subscribe button is the standard filled Action. connect-steps.tsx, which nothing
imports, is deleted with the brand constants nobody read.

Pages compared before and after at 1440x900 and 390x844: blockchain, brand,
directory, projects, settings, unsubscribe identical; founder 0.03% of pixels;
buying specialist's title snaps from 28 to the ramp's 26px and the newsletter's
button is now filled.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 03:49:42 -07:00

1091 lines
40 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 { COMPANY, DOCS, FOOT, FOOT_LINE, LOGIN, MENUS, OPEN, TRY, 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
* opens the company, the five menus follow with a mark before each label,
* search closes the row, and Log in ▾ and Try Hanzo stand at the far end. The
* shell's `HanzoHeader` has no company slot, no search in the row and no log-in
* menu, 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 five 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[] = [COMPANY, ...MENUS]
.flatMap((menu) => menu.groups.flatMap((group) => group.links.map((link) => ({ menu, link }))))
.concat(LOGIN.map((link) => ({ menu: COMPANY, link })))
.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>
)
}
/**
* Try Hanzo where the page already holds the sign-up: the landing's first
* screen carries the sign-up panel (components/home/Start), so the press brings that
* panel into view and puts focus on its first control, and nothing loads.
* Where the panel is not drawn at this width, or the page has none, the link
* goes on to the sign-up as it says. Reports whether it was taken here.
*/
function reach(e: { preventDefault: () => void }): boolean {
const panel = document.querySelector<HTMLElement>('[data-landing] [data-slot="hero"] [data-slot="signin"]')
if (!panel || !panel.getClientRects().length) return false
e.preventDefault()
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches
panel.scrollIntoView({ behavior: still ? 'auto' : 'smooth', block: 'center' })
panel.querySelector<HTMLElement>('button, input')?.focus({ preventScroll: true })
return true
}
/** 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 : (scrolled ? GLASS : 'transparent')}
backdropFilter={lifted ? 'none' : (scrolled ? BLUR : 'none')}
transition="quickest"
borderBottomWidth={0}
// inline-style: custom properties @hanzogui/shell's account menu reads; no prop sets a variable.
style={INK}
>
{menu(
COMPANY,
<XStack render="span" {...BRAND} {...(open === COMPANY.id ? { bg: WASH, boxShadow: RING } : {})}>
<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}
$lg={{ paddingInlineEnd: 0 }}
>
<HanzoWordmark label="Hanzo AI" size={22} data-tauri-drag-region="deep" />
</Anchor>
{/* The name goes home; the chevron beside it is its own stop and opens the company. */}
<Text
render="button"
aria-label={t('Company')}
aria-expanded={open === COMPANY.id}
aria-controls={`menu-${COMPANY.id}`}
onClick={press(COMPANY.id)}
onKeyDown={step(COMPANY.id)}
display="none"
$lg={{ display: 'grid' }}
placeItems="center"
width={30}
height="100%"
paddingBlock={0}
paddingInlineStart={0}
paddingInlineEnd={6}
borderWidth={0}
borderStartEndRadius={999}
borderEndEndRadius={999}
bg="transparent"
color="var(--foreground)"
cursor="pointer"
>
<Chevron turned={open === COMPANY.id} />
</Text>
</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} {...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} />
) : (
<XStack position="relative" display="none" $lg={{ display: 'flex' }} onPointerEnter={rest('login')} onPointerLeave={leave} onBlur={blur}>
<Text
render="button"
{...TRIGGER}
{...lit('login')}
aria-expanded={open === 'login'}
aria-controls="menu-login"
onClick={press('login')}
onKeyDown={step('login')}
>
{t('Log in')}
<Chevron turned={open === 'login'} />
</Text>
{open === 'login' ? (
<YStack
id="menu-login"
role="group"
aria-label={t('Log in')}
position="absolute"
t="calc(100% + 8px)"
insetInlineEnd={0}
display="grid"
gap={2}
minW={240}
p={6}
borderWidth={1}
borderColor="var(--border)"
rounded={12}
bg={PANEL}
boxShadow={DROP}
{...ARRIVE}
>
{LOGIN.map((link) => (
<Anchor
key={link.label}
href={link.href}
target="_blank"
rel="noreferrer"
onClick={close}
display="grid"
gap={2}
py={8}
px={10}
rounded={8}
color="var(--foreground)"
fontSize="$2"
lineHeight={20}
fontWeight="600"
textDecorationLine="none"
hoverStyle={{ bg: WASH }}
focusVisibleStyle={{ bg: WASH }}
>
{t(link.label)}
<Text render="small" fontSize="$1" lineHeight={20} fontWeight="400" color="var(--muted-foreground)">
{link.hint ? t(link.hint) : null}
</Text>
</Anchor>
))}
</YStack>
) : null}
</XStack>
)}
<Row link={signedIn ? OPEN : TRY} {...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">
{[COMPANY, ...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}
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>
) : (
<>
<Title sheet>{t('Log in')}</Title>
{LOGIN.map((link) => (
<Row key={link.label} link={link} onClick={onClose} {...DOOR} />
))}
</>
)}
<Row
link={signedIn ? OPEN : TRY}
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: 'YouTube', href: 'https://youtube.com/@HanzoAI' },
{ 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>
</>
)
}