Files
zeekayandHanzo Dev 751ef5c778 rename the framework to @hanzo/gui and the loader to @hanzogui/loader
Both old names are owned by a different npm account (artemis-prime), so
publishing them returns 403 — which is how this surfaced: the 8.0.0 run
shipped @hanzogui/logo and @hanzogui/demos depending on [email protected], a
version that cannot be published and therefore does not exist. Those two
were uninstallable.

@hanzo/gui and @hanzogui/* are ours, so the framework now carries the name
it is imported by everywhere downstream, and the loader moves under the
scope we control.

The rewrite is limited to package references. `DEBUG?.startsWith('hanzogui')`
is a log-namespace filter and join(dir, 'hanzogui') is a path — rewriting
either would change behaviour, so both are untouched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-30 20:23:18 -07:00

340 lines
7.8 KiB
TypeScript

import { LinearGradient } from '@hanzogui/linear-gradient'
import { Check, ChevronLeft, ChevronRight, Lightbulb } from '@hanzogui/lucide-icons-2'
import type React from 'react'
import { useRef, useState } from 'react'
import type { ScrollViewProps, XStackProps } from '@hanzo/gui'
import {
AnimatePresence,
Button,
Paragraph,
Separator,
View,
ScrollView,
XStack,
YStack,
styled,
} from '@hanzo/gui'
type PanelProps = XStackProps & {
title?: React.ReactNode
afterTitle?: React.ReactNode
icon?: React.ReactNode
steps?: React.ReactNode[]
chromeless?: boolean
}
const Panel = ({
children,
title,
icon,
afterTitle,
chromeless,
...props
}: PanelProps) => {
return (
<NoticeFrame
{...props}
{...(chromeless && {
borderWidth: 0,
padding: 0,
})}
>
{!!title && (
<>
<XStack items="center" justify="space-between" mb="$3">
<Paragraph size="$6" fontWeight="600">
{title}
</Paragraph>
<XStack items="center" gap="$4">
{afterTitle}
{icon}
</XStack>
</XStack>
<Separator borderColor="$color5" />
</>
)}
<XStack flex={1}>
<YStack flex={1} gap="$2">
{typeof children === 'string' ? (
<NoticeParagraph>{children}</NoticeParagraph>
) : (
children
)}
</YStack>
<YStack>{!title && icon}</YStack>
</XStack>
</NoticeFrame>
)
}
export const StudioNotice = ({ children, steps, ...props }: PanelProps) => {
const [[page, direction], setPage] = useState([0, 0])
const total = steps?.length ?? 0
const index = wrap(0, total, page)
const paginate = (newDirection: number) => {
setPage([page + newDirection, newDirection])
}
const enterVariant = direction === 1 || direction === 0 ? 'isRight' : 'isLeft'
const exitVariant = direction === 1 ? 'isLeft' : 'isRight'
return (
<Panel
icon={<Lightbulb size="$1" color="$color7" />}
afterTitle={
!steps ? null : (
<XStack gap="$2" items="center">
<Paragraph mr="$2" size="$2" color="$color9">
{index + 1}/{total}
</Paragraph>
<Button
aria-label="Carousel left"
icon={ChevronLeft}
size="$2"
circular
elevation="$0.5"
onPress={() => paginate(-1)}
/>
<Button
aria-label="Carousel left"
icon={ChevronRight}
size="$2"
circular
elevation="$0.5"
onPress={() => paginate(1)}
/>
</XStack>
)
}
{...props}
>
{children ? (
children
) : steps ? (
<YStack flex={1} overflow="hidden" mx="$-4" my="$-2">
<ScrollViewWithFade showsVerticalScrollIndicator={false}>
<AnimatePresence
initial={false}
enterVariant={enterVariant}
exitVariant={exitVariant}
>
<YStackEnterable
key={page}
transition="quicker"
gap="$3"
position="absolute"
p="$4"
>
{steps[index]}
</YStackEnterable>
</AnimatePresence>
</ScrollViewWithFade>
</YStack>
) : null}
</Panel>
)
}
interface Size {
width: number
height: number
}
const ScrollViewWithFade = ({
children,
scrollFadeThreshold = 0,
...props
}: ScrollViewProps & {
scrollFadeThreshold?: number
}) => {
const state = useRef<{
contentSize?: Size
outerSize?: Size
scrollPosition: Offset
}>({
scrollPosition: {
x: 0,
y: 0,
},
})
const [status, setStatus] = useState<
'fade-top' | 'fade-bottom' | 'fade-both' | 'no-fade'
>('no-fade')
function update() {
const horizontal = !!props.horizontal
const contentSize = state.current?.contentSize
const outerSize = state.current?.outerSize
const contentOffset = state.current?.scrollPosition
if (!contentSize || !outerSize) return
const isScrollable = horizontal
? contentSize.width > outerSize.width
: contentSize.height > outerSize.height
if (!isScrollable) {
setStatus('no-fade')
return
}
const check: CheckScrollPositionProps = {
contentOffset,
contentSize,
horizontal,
threshold: scrollFadeThreshold,
outerSize,
}
const atStart = isAtStart(check)
const atEnd = isAtEnd(check)
if (!atStart && !atEnd) {
setStatus('fade-both')
return
}
if (atStart) {
setStatus('fade-bottom')
return
}
setStatus('fade-top')
}
return (
<View flex={1} position="relative">
{(status === 'fade-top' || status === 'fade-both') && (
<LinearGradient
pointerEvents="none"
z={1000}
position="absolute"
l={0}
r={0}
height="20%"
colors={['$background', '$background0']}
/>
)}
{(status === 'fade-bottom' || status === 'fade-both') && (
<LinearGradient
pointerEvents="none"
z={1000}
position="absolute"
l={0}
r={0}
height="20%"
b={0}
colors={['$background0', '$background']}
/>
)}
<ScrollView
{...props}
scrollEventThrottle={16}
contentContainerStyle={{
flex: 1,
}}
onLayout={(e) => {
if (!state.current.outerSize) {
state.current.outerSize = e.nativeEvent.layout
update()
}
}}
onScroll={(e) => {
state.current.contentSize = e.nativeEvent.contentSize
state.current.outerSize = e.nativeEvent.layoutMeasurement
state.current.scrollPosition = e.nativeEvent.contentOffset
update()
}}
>
<YStack
flex={1}
onLayout={(e) => {
if (!state.current.contentSize) {
state.current.contentSize = {
height: e.nativeEvent.layout.height + 10,
width: e.nativeEvent.layout.width,
}
update()
}
}}
>
{children}
</YStack>
</ScrollView>
</View>
)
}
interface ScrollLayout {
height: number
width: number
}
interface Offset {
x: number
y: number
}
interface CheckScrollPositionProps {
threshold: number
horizontal?: boolean
outerSize: ScrollLayout
contentOffset: Offset
contentSize: Size
}
function isAtStart({ horizontal, contentOffset, threshold }: CheckScrollPositionProps) {
return horizontal ? contentOffset.x <= threshold : contentOffset.y <= threshold
}
function isAtEnd({
horizontal,
outerSize,
contentOffset,
contentSize,
threshold,
}: CheckScrollPositionProps) {
return horizontal
? outerSize.width + contentOffset.x >= contentSize.width - threshold
: outerSize.height + contentOffset.y >= contentSize.height - threshold
}
const wrap = (min: number, max: number, v: number) => {
const rangeSize = max - min
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min
}
const YStackEnterable = styled(YStack, {
variants: {
isLeft: { true: { x: -30, opacity: 0 } },
isRight: { true: { x: 30, opacity: 0 } },
} as const,
})
export const StudioSuccess = (props: PanelProps) => {
return <Panel theme="green" icon={<Check size="$1" color="$color7" />} {...props} />
}
export const NoticeParagraph = styled(Paragraph, {
pr: '$4',
size: '$3',
color: '$color11',
})
const NoticeFrame = styled(YStack, {
className: 'no-opacity-fade',
borderWidth: 2,
borderColor: '$color6',
p: '$4',
py: '$3',
bg: '$background',
rounded: '$4',
gap: '$3',
position: 'relative',
})