mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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
1091 lines
40 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|