Files
zeekayandHanzo Dev d504ef37b2 Hold the page to one gutter at every width
The responsive section measured itself against a second container. Its
breakpoint ruler rode Container — the docs page's measure and gutter —
nested in the ContainerLarge every other section stands on, so a marker
labelled 500 stood 522px from the mock it measures, and below sm a
hand-fitted translate put the mock 25px left of its own heading, off the
page. Both take the section's own box now.

The preview strip scrolled to an arithmetic copy of its padding and gap
that had drifted from them by one gap per stop, so the ninth colour sat a
whole card off centre. It asks the layout instead.

A stop reaches the swatch, the demo, the ink and the stored accent, never
the ground, and a lamp carried past the hero is the ground by another
route — the wash still under the player row. The glow stays at the hero.

Two footer columns had no basis to size from once stacked and drew over
the next, nine swatches could not fit 390 in one row, and a title glued
to its last word was split mid-word instead.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 03:53:08 -07:00

876 lines
22 KiB
TypeScript

import { LogoWords, setTintFamily, ThemeTint, useTint } from '@hanzogui/logo'
import { Check, ExternalLink, Menu } from '@hanzogui/lucide-icons-2'
import { isTouchable, useGet, useMedia } from '@hanzogui/web'
import { useFocusEffect, usePathname, useRouter } from 'one'
import * as React from 'react'
import { useWindowDimensions, type LayoutRectangle } from 'react-native'
import {
Adapt,
Anchor,
AnimatePresence,
Button,
Circle,
debounce,
isClient,
Paragraph,
Popover,
Separator,
Sheet,
SizableText,
styled,
TooltipGroup,
useComposedRefs,
useDebounce,
View,
XGroup,
XStack,
YStack,
type PopoverProps,
} from '@hanzo/gui'
import { Link } from '~/components/Link'
import { GithubIcon } from '~/features/icons/GithubIcon'
import { seasons } from '~/features/site/seasons/SeasonTogglePopover'
import { ThemeToggle } from '~/features/site/theme/ThemeToggle'
import { useThemeBuilderStore } from '~/features/studio/theme/store/ThemeBuilderStore'
import { DocsMenuContents } from '../../docs/DocsMenuContents'
import { useDocsMenu } from '../../docs/useDocsMenu'
import { PromoCardTheme } from './PromoCards'
import { SearchButton } from './SearchButton'
import type { HeaderProps } from './types'
// 60px, the bar every Hanzo surface wears. LogoWords sizes the mark by
// `downscale` from 373/3 px wide; its box is 2893.2 by 722 units with the H cap
// at 710. The cap is set to that of a 24px Zen line (0.71em), so the mark and
// the word beside it share one cap line.
const HEADER = 60
const LINE = 24
const WORDMARK_HEIGHT = (LINE * 0.71 * 722) / 710
const WORDMARK_DOWNSCALE = 373 / 3 / ((WORDMARK_HEIGHT * 2893.2) / 722)
export function Header(props: HeaderProps) {
const [isScrolled, setIsScrolled] = React.useState(false)
if (isClient) {
React.useEffect(() => {
const onScroll = () => {
setIsScrolled(window.scrollY > 30)
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => {
window.removeEventListener('scroll', onScroll)
}
}, [])
}
return (
<>
<XStack
position="fixed"
t={0}
l={0}
r={0}
items="center"
pointerEvents="none"
justify="center"
z={10000}
className="all ease-out s1"
$gtSm={{
px: '$1',
}}
>
<XStack pointerEvents="auto" width="100%" maxW={1200} position="relative">
<XStack
className={`ease-out all ms300`}
overflow="hidden"
contain="paint"
width="100%"
bg="transparent"
rounded="$10"
borderColor={'transparent'}
borderWidth={0.5}
$sm={{
rounded: 0,
borderWidth: 0,
}}
{...(isScrolled && {
$gtSm: {
borderColor: '$color5',
y: 6,
},
})}
{...(props.hasBanner && {
$gtSm: {
y: isScrolled ? 6 : 35,
},
})}
>
<YStack
position="absolute"
inset={0}
className={'ease-out all ms100'}
style={{
...(isScrolled && {
backdropFilter: `blur(16px)`,
WebkitBackdropFilter: `blur(16px)`,
}),
}}
/>
<YStack
opacity={isScrolled ? 0.6 : 0}
className={`ease-out all ms300`}
fullscreen
bg="$color2"
/>
<YStack mx="auto" px="$4" width="100%">
<ThemeTint disable>
<HeaderContents floating {...props} />
</ThemeTint>
</YStack>
</XStack>
{/* do shadow separate so we can contain paint because its causing perf issues */}
<XStack
className="ease-in-out all ms200"
z={-1}
rounded="$10"
fullscreen
boxShadow="0 8px 20px $shadow3"
opacity={0}
{...(isScrolled && {
$gtSm: {
opacity: 1,
py: '$2',
y: 5,
},
})}
/>
</XStack>
</XStack>
<YStack height={HEADER} width="100%" />
</>
)
}
const tooltipDelay = { open: 0, close: 150 }
// A finger wants 44px, and under sm the pointer is one. The menu button already
// stands that tall; the toggle, the search and the octocat are given it here.
const touchTarget = { $sm: { minW: 44, minH: 44 } } as const
export const HeaderContents = React.memo((props: HeaderProps) => {
const pathname = usePathname()
const isHome = pathname === '/'
return (
<XStack items="center" position="relative" render="header" height={HEADER} z={50000}>
<XStack items="center" gap="$4" $sm={{ gap: '$2' }}>
<Link href="/" aria-label="Homepage">
<XStack
cursor={isHome ? 'default' : 'pointer'}
items="baseline"
gap="$2"
pointerEvents="auto"
>
<LogoWords downscale={WORDMARK_DOWNSCALE} />
<SizableText
fontFamily="$heading"
fontSize={LINE}
lineHeight={LINE}
fontWeight="$1"
color="$color12"
userSelect="none"
>
GUI
</SizableText>
</XStack>
</Link>
<TooltipGroup delay={tooltipDelay}>
<XGroup bg="transparent" items="center" size="$4">
<XGroup.Item>
<ThemeToggle borderWidth={0} chromeless {...touchTarget} />
</XGroup.Item>
</XGroup>
</TooltipGroup>
<SearchButton size="$2" rounded="$10" elevation="$0.5" {...touchTarget} />
<Link target="_blank" href="https://github.com/hanzoai/gui">
<XStack group containerType="normal">
<XStack
items="center"
justify="center"
gap="$2"
p="$2"
opacity={0.9}
hoverStyle={{ opacity: 1 }}
{...touchTarget}
>
<GithubIcon width={22} />
<>
<SizableText
$xl={{ display: 'none' }}
size="$3"
color="$color12"
opacity={0.5}
$group-hover={{
opacity: 0.8,
}}
>
GitHub
</SizableText>
</>
</XStack>
</XStack>
</Link>
</XStack>
<View flex={1} />
<XStack height={40} justify="flex-end" pointerEvents="auto" render="nav">
<XStack items="center" gap="$2">
<HeaderLinksPopover>
<HeaderLink id="core" href="/docs/intro/introduction">
Core
</HeaderLink>
<HeaderLink id="ui" href="https://ui.hanzo.ai">
UI
</HeaderLink>
<HeaderLink id="theme" href="/theme">
Theme
</HeaderLink>
<HeaderMenuButton />
</HeaderLinksPopover>
</XStack>
</XStack>
</XStack>
)
})
const HeaderMenuButton = () => {
const { open, setOpen } = useDocsMenu()
const context = React.useContext(SlidingPopoverContext)
return (
<Popover.Trigger>
<SlidingPopoverTarget id="menu">
<Button
size="$5"
circular
my={-1}
bg="transparent"
borderWidth={0}
onPress={(e) => {
if (isTouchable) {
setOpen(!open)
// Ensure the active state is set for the menu to open properly on mobile
// On mobile, we just need to set the active state, not the layout
// The Sheet doesn't need positioning data
context.setActive('menu')
return
}
if (isOnLink) {
e.preventDefault()
e.stopPropagation()
return
}
if (open) {
setOpen(false)
return
}
}}
aria-label="Open the main menu"
hoverStyle={{
bg: '$shadow1',
}}
>
<Circle size={34} items="center" justify="center">
<Menu size={20} />
</Circle>
</Button>
</SlidingPopoverTarget>
</Popover.Trigger>
)
}
let isOnMenu = false
const isOnLink = new Set<string>()
export const HeaderLinksPopover = (props: PopoverProps) => {
const popoverRef = React.useRef<Popover>(null)
const [active, setActive] = React.useState<ID | ''>('')
const close = () => {
setActive('')
popoverRef.current?.close()
}
const val = React.useMemo(() => {
return {
setActive(id: ID, layout: LayoutRectangle) {
popoverRef.current?.anchorTo(layout)
setActive(id)
},
close,
}
}, [])
const check = React.useRef<any>(undefined)
const checkForClose = () => {
if (isTouchable) return
check.current = setInterval(() => {
if (!isOnMenu && !isOnLink.size) {
close()
}
}, 500)
}
const cancelCheckForClose = () => {
if (isTouchable) return
clearInterval(check.current)
}
return (
<Popover
disableRTL
hoverable={{
delay: 0,
restMs: 0,
move: false,
enabled: !isTouchable,
}}
stayInFrame={{
padding: 20,
}}
open={!!active}
onOpenChange={(val, event) => {
if (!val) {
setActive('')
}
}}
ref={popoverRef}
{...props}
>
<SlidingPopoverContext.Provider value={val}>
<XStack onMouseEnter={cancelCheckForClose} onMouseLeave={checkForClose}>
{props.children}
</XStack>
<HeaderLinksPopoverContent active={active} />
</SlidingPopoverContext.Provider>
<Adapt platform="touch" when="sm">
<Sheet transition="medium" zIndex={100000000} modal dismissOnSnapToBottom>
<Sheet.Frame>
<Sheet.ScrollView showsVerticalScrollIndicator={false}>
<Adapt.Contents />
</Sheet.ScrollView>
</Sheet.Frame>
<Sheet.Overlay z={100} bg="$shadow4" />
</Sheet>
</Adapt>
</Popover>
)
}
type ID = 'core' | 'ui' | 'theme' | 'menu'
export const HeaderLink = (props: { id: ID; children: string; href: string }) => {
const pathname = usePathname()
const section = getDocsSectionFromPath(pathname)
const isActive =
props.id === section || (props.id === 'theme' && pathname.startsWith('/theme'))
// Another host: a plain anchor, with no menu of this site's pages under it.
if (props.href.startsWith('http')) {
return (
<HeadAnchor href={props.href} $sm={{ display: 'none' }}>
{props.children}
</HeadAnchor>
)
}
return (
<SlidingPopoverTarget id={props.id}>
<Link asChild href={props.href as any}>
<HeadAnchor
{...(isActive && { active: true })}
$sm={{
display: 'none',
}}
>
{props.children}
</HeadAnchor>
</Link>
</SlidingPopoverTarget>
)
}
const SlidingPopoverContext = React.createContext({
setActive(id: ID, layout?: LayoutRectangle) {},
close() {},
})
export const SlidingPopoverTarget = YStack.styleable<{ id: ID }>(
({ id, ...props }, ref) => {
const context = React.useContext(SlidingPopoverContext)
const [layout, setLayout] = React.useState<LayoutRectangle | undefined>()
const triggerRef = React.useRef<HTMLElement>(null)
const combinedRef = useComposedRefs(ref)
const [hovered, setHovered] = React.useState(false)
const getLayout = useGet(layout)
React.useEffect(() => {
if (!hovered) return
const handleMove = debounce(() => {
const layout = triggerRef.current?.getBoundingClientRect()
if (layout) {
setLayout(layout as any)
}
}, 32)
window.addEventListener('resize', handleMove)
return () => {
window.removeEventListener('resize', handleMove)
}
}, [hovered])
const setActive = () => {
const layout = getLayout()
if (layout) {
isOnLink.add(id)
context.setActive(id, layout)
}
setHovered(true)
}
const setActiveDebounced = useDebounce(setActive, 100)
return (
<YStack
onMouseEnter={setActiveDebounced}
onMouseLeave={() => {
setActiveDebounced.cancel()
isOnLink.delete(id)
setHovered(false)
}}
onPress={() => {
if (isTouchable) return
setActiveDebounced.cancel()
setTimeout(() => {
context.close()
}, 400)
}}
onLayout={(e) => {
React.startTransition(() => {
setLayout({
...e.nativeEvent.layout,
// @ts-ignore
x: e.nativeEvent.layout.pageX,
// @ts-ignore
y: e.nativeEvent.layout.pageY,
})
})
}}
ref={combinedRef}
{...props}
/>
)
}
)
const order = ['', 'core', 'ui', 'theme', 'menu']
const HeaderLinksPopoverContent = React.memo((props: { active: ID | '' }) => {
const [active, setActive] = React.useState<ID>(
props.active === '' ? 'menu' : props.active
)
// Fix: Move setState to useEffect to avoid React #185 error (setState during render)
React.useEffect(() => {
if (props.active !== '' && active !== props.active) {
setActive(props.active)
}
}, [props.active])
const pathname = usePathname()
const context = React.useContext(SlidingPopoverContext)
const pointerFine = !isTouchable
const isOnlyShowingMenu = useMedia().maxMd
useFocusEffect(() => {
context.close()
}, [pathname])
const last = React.useRef(active)
const curI = order.indexOf(active)
const lastI = order.indexOf(last.current)
const going = curI > lastI ? 1 : -1
React.useEffect(() => {
last.current = active
}, [active])
const { height } = useWindowDimensions()
const maxHeight = height - 150
const heights = {
core: Math.min(maxHeight, 1300),
compiler: 117,
ui: Math.min(maxHeight, 1300),
theme: 240,
menu: Math.min(maxHeight, isOnlyShowingMenu ? 1000 : 520),
}
return (
<Popover.Content
onMouseEnter={() => {
isOnMenu = true
}}
onMouseLeave={() => {
isOnMenu = false
}}
animatePosition
transition="medium"
bg="$background06"
backdropFilter="blur(40px)"
maxH="90vh"
maxW={360}
minW={360}
elevation="$2"
p={0}
rounded="$6"
opacity={1}
y={0}
enterStyle={{
y: 3,
opacity: 0,
}}
exitStyle={{
y: 5,
opacity: 0,
}}
>
<Popover.Arrow transition="medium" animatePosition bg="$background06" size="$4" />
{pointerFine ? (
<YStack
width="100%"
transition="200ms"
height={heights[active]}
maxHeight="90vh"
overflow="hidden"
rounded="$6"
>
<AnimatePresence custom={{ going }} initial={false}>
<HeaderMenuContents key={active} id={active} />
</AnimatePresence>
</YStack>
) : (
<YStack p="$4">
<HeaderMenuContents key={active} id={active} />
</YStack>
)}
</Popover.Content>
)
})
const getDocsSectionFromPath = (pathName: string): 'core' | 'ui' | null => {
if (!pathName || pathName === '/' || pathName === '') return null
if (pathName.startsWith('/ui/')) return 'ui'
if (pathName.startsWith('/docs') || pathName.startsWith('/blog')) return 'core'
return null
}
const ActivePageDocsMenuContents = () => {
const pathName = usePathname()
const section = getDocsSectionFromPath(pathName)
if (!section) return null
return (
<>
<Separator bg="$color02" opacity={0.25} my="$4" />
<DocsMenuContents inMenu section={section} />
</>
)
}
const HeaderMenuContents = (props: { id: ID }) => {
const isOnlyShowingMenu = useMedia().maxMd
const isMobile = isTouchable && isOnlyShowingMenu
const contents = (() => {
if (props.id === 'menu') {
return (
<>
<HeaderMenuMoreContents />
<Separator borderColor="$color02" opacity={0.25} my="$2" />
{isOnlyShowingMenu && (
<>
<ActivePageDocsMenuContents />
<Separator borderColor="$color02" opacity={0.25} my="$2" />
</>
)}
<SeasonChooser />
</>
)
}
if (props.id === 'theme') {
return (
<YStack flex={1} gap="$2" flexBasis="auto">
<PromoCardTheme />
<Paragraph
pointerEvents="none"
borderWidth={0.5}
bg="$color6"
rounded="$5"
fontFamily="$mono"
size="$4"
opacity={0.5}
p="$4"
>
Create themes to preview them across the site.
{`\n`}
<Link href="/theme" theme="blue" style={{ pointerEvents: 'auto' }}>
Go to Theme Builder →
</Link>
</Paragraph>
</YStack>
)
}
return <DocsMenuContents inMenu section={props.id} />
})()
// For mobile, render content directly without Frame wrapper
if (isMobile) {
return (
<YStack width="100%" p="$3">
{contents}
</YStack>
)
}
// For desktop, use Frame wrapper
return (
<Frame>
{/* BUG: when adapted to sheet this scrollview will get scroll events not the inner one */}
<Popover.ScrollView
showsVerticalScrollIndicator={false}
style={{
flex: 1,
width: '100%',
}}
contentContainerStyle={{ width: '100%' }}
>
<YStack width="100%" p="$3">
{contents}
</YStack>
</Popover.ScrollView>
</Frame>
)
}
const HeaderMenuMoreContents = () => {
const router = useRouter()
const handlePress = (e: any) => {
e.preventDefault()
e.stopPropagation()
router.push(e.target.href)
}
return (
<YStack gap="$2" aria-label="Home menu contents">
<YStack gap="$2" $gtSm={{ display: 'none' }}>
<Link asChild href="/">
<HeadAnchor menu>Home</HeadAnchor>
</Link>
<Separator bg="$color02" opacity={0.25} my="$2" />
</YStack>
<XStack flex={1} flexBasis="auto" flexWrap="wrap" gap="$2" width="100%">
<Link asChild href="/docs/intro/introduction">
<HeadAnchor menu half>
Core
</HeadAnchor>
</Link>
<Link asChild href="/docs/intro/compiler-install" onPress={handlePress}>
<HeadAnchor menu half>
Compiler
</HeadAnchor>
</Link>
<HeadAnchor href="https://ui.hanzo.ai" menu half>
UI
</HeadAnchor>
<Link asChild href="/theme" onPress={handlePress}>
<HeadAnchor menu half>
Theme
</HeadAnchor>
</Link>
</XStack>
<Separator bg="$color02" opacity={0.25} my="$2" />
<Link asChild href="https://github.com/hanzoai/gui">
<HeadAnchor target="_blank" menu>
Github{' '}
<YStack display={'inline-block' as any} y={10} my={-20} opacity={0.8}>
<GithubIcon width={14} />
</YStack>
</HeadAnchor>
</Link>
<Link asChild href="/blog">
<HeadAnchor menu>Blog</HeadAnchor>
</Link>
<Link asChild href="https://github.com/sponsors/hanzoai">
<HeadAnchor menu target="_blank">
Sponsor
<YStack display={'inline-block' as any} y={0} my={-20} ml={12} opacity={0.8}>
<ExternalLink size={10} opacity={0.5} />
</YStack>
</HeadAnchor>
</Link>
</YStack>
)
}
const SeasonChooser = () => {
const { name } = useTint()
return (
<XStack flexWrap="wrap" items="center" justify="center">
{Object.keys(seasons).map((seasonName) => {
const isActive = name === seasonName
return (
<Circle
key={seasonName}
size="$4"
cursor="pointer"
items="center"
justify="center"
hoverStyle={{
bg: '$backgroundHover',
}}
pressStyle={{
bg: '$backgroundPress',
}}
{...(isActive && {
bg: '$color5',
hoverStyle: {
bg: '$color5',
},
})}
onPress={() => {
setTintFamily(seasonName as any)
}}
>
<SizableText size="$5">{seasons[seasonName]}</SizableText>
</Circle>
)
})}
</XStack>
)
}
const HeadAnchor = styled(Anchor, {
size: '$2',
fontWeight: '600',
px: '$4',
py: '$3',
cursor: 'pointer',
color: '$color11',
hoverStyle: {
color: '$color',
rounded: '$3',
},
focusVisibleStyle: {
outlineColor: '$outlineColor',
outlineWidth: 2,
outlineStyle: 'solid',
outlineOffset: -2,
},
pressStyle: {
opacity: 0.25,
},
variants: {
active: {
true: {
color: '$color12',
},
},
menu: {
true: {
fontWeight: '200',
letterSpacing: 1,
width: '100%',
flex: 1,
flexBasis: 'auto',
p: '$2',
px: '$4',
hoverStyle: {
backgroundColor:
'color-mix(in srgb, var(--color8) 10%, transparent 50%)' as any,
},
},
},
half: {
true: {
maxWidth: '48.5%',
overflow: 'hidden',
},
},
} as const,
})
const Frame = styled(YStack, {
className: 'header-popover-frame',
transition: '300ms',
flex: 1,
rounded: '$5',
overflow: 'hidden',
position: 'absolute',
t: 0,
l: 0,
r: 0,
b: 0,
z: 1,
x: 0,
opacity: 1,
variants: {
// 1 = right, 0 = nowhere, -1 = left
going: {
':number': (going) => ({
enterStyle: {
x: going > 0 ? 50 : -50,
opacity: 0,
},
exitStyle: {
zIndex: 0,
x: going < 0 ? 50 : -50,
opacity: 0,
},
}),
},
} as const,
})