mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-25 02:31:41 +00:00
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]>
876 lines
22 KiB
TypeScript
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,
|
|
})
|