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]>
This commit is contained in:
2026-09-08 03:53:08 -07:00
co-authored by Hanzo Dev
parent cb6b46ba10
commit d504ef37b2
13 changed files with 142 additions and 198 deletions
@@ -3,7 +3,7 @@ import { useLoader } from 'one'
import { H2, H3, Paragraph, Text, XStack, YStack, styled } from '@hanzo/gui'
import { HeadInfo } from '~/components/HeadInfo'
import { Link } from '~/components/Link'
import { SubTitle, nbspLastWord } from '~/components/SubTitle'
import { SubTitle } from '~/components/SubTitle'
import { DocsPageFrame } from '~/features/docs/DocsPageFrame'
import type { Entry } from '~/api'
import { id, named, packages } from '~/features/docs/packages'
@@ -50,7 +50,7 @@ export default function ApiPackagePage() {
/>
<HomeH1>{pack.name}</HomeH1>
<SubTitle>{nbspLastWord(pack.description ?? '')}</SubTitle>
<SubTitle>{pack.description ?? ''}</SubTitle>
<Mono color="$color11">
{`v${pack.version} · ${pack.entry} · ${values} values · ${pack.entries.length - values} types`}
@@ -3,7 +3,7 @@ import { getMDXComponent } from '@vxrn/mdx-rust/client'
import { useMemo } from 'react'
import { useLoader } from 'one'
import { HeadInfo } from '~/components/HeadInfo'
import { SubTitle, nbspLastWord } from '~/components/SubTitle'
import { SubTitle } from '~/components/SubTitle'
import { DocsPageFrame } from '~/features/docs/DocsPageFrame'
import { useDocsMenu } from '~/features/docs/useDocsMenu'
import { components } from '~/features/mdx/MDXComponents'
@@ -59,7 +59,7 @@ export default function DocCorePage() {
],
}}
/>
<HomeH1>{nbspLastWord(frontmatter.title)}</HomeH1>
<HomeH1>{frontmatter.title}</HomeH1>
<SubTitle>{frontmatter.description || ''}</SubTitle>
<ThemeTint>
<Component components={components as any} />
@@ -3,7 +3,7 @@ import { getMDXComponent } from '@vxrn/mdx-rust/client'
import { useMemo } from 'react'
import { useLoader } from 'one'
import { HeadInfo } from '~/components/HeadInfo'
import { SubTitle, nbspLastWord } from '~/components/SubTitle'
import { SubTitle } from '~/components/SubTitle'
import { DocsPageFrame } from '~/features/docs/DocsPageFrame'
import { useDocsMenu } from '~/features/docs/useDocsMenu'
import { components } from '~/features/mdx/MDXComponents'
@@ -59,8 +59,8 @@ export default function DocGuidesPage() {
],
}}
/>
<HomeH1>{nbspLastWord(frontmatter.title)}</HomeH1>
<SubTitle>{nbspLastWord(frontmatter.description || '')}</SubTitle>
<HomeH1>{frontmatter.title}</HomeH1>
<SubTitle>{frontmatter.description || ''}</SubTitle>
<ThemeTint>
<Component components={components as any} />
</ThemeTint>
@@ -3,7 +3,7 @@ import { getMDXComponent } from '@vxrn/mdx-rust/client'
import { useLoader } from 'one'
import { useMemo } from 'react'
import { HeadInfo } from '~/components/HeadInfo'
import { SubTitle, nbspLastWord } from '~/components/SubTitle'
import { SubTitle } from '~/components/SubTitle'
import { GuiExamples } from '~/components/GuiExamples'
import { DocsPageFrame } from '~/features/docs/DocsPageFrame'
import { useDocsMenu } from '~/features/docs/useDocsMenu'
@@ -68,8 +68,8 @@ export default function DocIntroPage() {
],
}}
/>
<HomeH1>{nbspLastWord(frontmatter.title)}</HomeH1>
<SubTitle>{nbspLastWord(frontmatter.description || '')}</SubTitle>
<HomeH1>{frontmatter.title}</HomeH1>
<SubTitle>{frontmatter.description || ''}</SubTitle>
<ThemeTint>
<GuiExamples.Provider value={examples}>
<Component components={components as any} />
@@ -12,7 +12,7 @@ import { useDocsMenu } from '~/features/docs/useDocsMenu'
import { useIsDocsTinted } from '~/features/docs/docsTint'
import { components } from '~/features/mdx/MDXComponents'
import { getOgUrl } from '~/features/site/getOgUrl'
import { nbspLastWord, SubTitle } from '../../../../components/SubTitle'
import { SubTitle } from '../../../../components/SubTitle'
import { HomeH1 } from '../../../../features/site/home/HomeHeaders'
export async function generateStaticParams() {
@@ -104,9 +104,9 @@ export function DocComponentsPage() {
}}
/>
<HomeH1>{nbspLastWord(frontmatter.title)}</HomeH1>
<HomeH1>{frontmatter.title}</HomeH1>
<SubTitle>{nbspLastWord(frontmatter.description || '')}</SubTitle>
<SubTitle>{frontmatter.description || ''}</SubTitle>
<MDXProvider frontmatter={frontmatter}>
<DocsThemeTint>
+1 -19
View File
@@ -1,23 +1,5 @@
import { Fragment } from 'react'
import { H3 } from '@hanzo/gui'
export const nbspLastWord = (sentence: string) => {
if (typeof sentence !== 'string') {
return sentence
}
const titleWords = sentence.split(' ')
if (titleWords.length === 1) {
return sentence
}
return titleWords.map((word, i) => {
return i === titleWords.length - 1 ? (
<Fragment key={i}>&nbsp;{word}</Fragment>
) : (
<Fragment key={i}> {word}</Fragment>
)
})
}
export const SubTitle = ({ children, ...props }) => {
if (!children) {
return null
@@ -48,7 +30,7 @@ export const SubTitle = ({ children, ...props }) => {
}}
{...props}
>
{nbspLastWord(childText)}
{childText}
</H3>
)
}
@@ -16,6 +16,7 @@ export const Footer = memo(() => {
$sm={{ items: 'center' }}
py="$5"
flex={2}
flexBasis="auto"
mb="$2"
px="$6"
gap="$4"
@@ -75,6 +76,7 @@ export const Footer = memo(() => {
px="$4"
py="$5"
flex={1.5}
flexBasis="auto"
gap="$3"
>
<H4 mb="$3" fontFamily="$mono" size="$4" letterSpacing={0.5} opacity={0.5}>
@@ -152,6 +152,10 @@ export function Header(props: HeaderProps) {
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 === '/'
@@ -181,23 +185,25 @@ export const HeaderContents = React.memo((props: HeaderProps) => {
</Link>
<TooltipGroup delay={tooltipDelay}>
<XGroup maxH={32} bg="transparent" items="center" size="$4">
<XGroup bg="transparent" items="center" size="$4">
<XGroup.Item>
<ThemeToggle borderWidth={0} chromeless />
<ThemeToggle borderWidth={0} chromeless {...touchTarget} />
</XGroup.Item>
</XGroup>
</TooltipGroup>
<SearchButton size="$2" rounded="$10" elevation="$0.5" />
<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} />
<>
@@ -227,6 +227,10 @@ const CodeExamples = memo(({ examples = [], title }: any) => {
flexBasis="auto"
height={325}
maxH={325}
// 325 is what makes the two panes match when they stand side by
// side. Stacked, they no longer face each other, and a short sample
// was holding a tall one's room open under it.
$sm={{ height: 'auto' }}
flex={1}
language={example.language}
source={example.code}
@@ -4,97 +4,42 @@ import { AnimatePresence, YStack, isClient, useMedia } from '@hanzo/gui'
import { useTintSectionIndex } from './TintSection'
const positions = [
[-100 * 2, 420 * 1.5, 100],
[-230 * 2, 64 * 1.5, 0],
[212 * 2, 127 * 1.5, 0],
[-135 * 2, 11 * 1.5, 0],
[268 * 2, 61 * 1.5, 0],
[-20 * 2, 145 * 1.5, 0],
[336 * 2, 104 * 1.5, 0],
[-141 * 2, 30 * 1.5, 0],
[369 * 2, 98 * 1.5, 0],
[-403 * 2, 1 * 1.5, 0],
[339 * 2, 138 * 1.5, 0],
[-42 * 2, 106 * 1.5, 0],
[404 * 2, 86 * 1.5, 0],
[-490 * 2, 60 * 1.5, 0],
[155 * 2, 9 * 1.5, 0],
]
// Three lamps behind the hero, in the ramp's next three stops.
//
// They stop at the hero. A stop reaches the swatch, the demo, the ink and the
// stored accent — never the ground — and a lamp carried down the page is the
// ground by another route: it is what put a wash under the player row and left
// the section brown at orange and olive at yellow. Below the hero the sections
// stand on the neutral scale, which is where they were published.
const scales = [0.94, 0.51, 1.06]
const scales = [
[0.91, 1.05, 1.08],
[0.92, 1.03, 1.07],
[0.93, 1.04, 1.09],
[0.94, 1.02, 1.06],
[0.95, 1.01, 1.1],
[0.96, 1.0, 1.05],
[0.97, 1.06, 1.04],
[0.98, 1.07, 1.03],
[0.99, 1.08, 1.02],
[1.0, 1.09, 1.01],
[1.01, 1.1, 0.99],
[1.02, 1.05, 0.98],
[1.03, 1.04, 0.97],
[1.04, 1.03, 0.96],
[1.05, 1.02, 0.95],
]
export const HomeGlow = memo(() => {
const { tints, tint, tintAlt, tintIndex } = useTint()
const [sectionIndex, setSectionIndex] = useState(0)
const isOnHeroBelow = sectionIndex <= 1
const [scrollTop, setScrollTop] = useState(0)
const xs = 400
const scale = isOnHeroBelow ? 2 : 3
const [atHero, setAtHero] = useState(true)
const { reduceMotion } = useMedia()
if (isClient) {
useTintSectionIndex((index) => {
setSectionIndex(index)
// const dims = tintSectionDimensions[index]
// console.log('index', index, dims)
const sy = document.documentElement?.scrollTop ?? 0
setScrollTop(sy + 100)
setAtHero(index <= 1)
})
}
const glows = useMemo(() => {
return [
tints[(tintIndex - 0) % tints.length],
tints[(tintIndex + 1) % tints.length],
tints[(tintIndex + 2) % tints.length],
].map((curTint, i) => {
if (!atHero) return null
return scales.map((scale, i) => {
const curTint = tints[(tintIndex + i) % tints.length]
if (!curTint) {
// TODO this is being hit on quick move to T
return null
}
const isOpposing = tintIndex % 2 === 0
const isAlt = i === 1
const xRand = isOnHeroBelow
? 1
: positions[(isOpposing ? 2 - i : i) % positions.length][0]
const yRand = isOnHeroBelow
? 1
: positions[(isOpposing ? 2 - i : i) % positions.length][1]
const heroBelowShift = tintIndex === 2 ? -100 : tintIndex === 4 ? 100 : 0
const x =
xRand +
(isOnHeroBelow ? heroBelowShift + (isAlt ? -250 : 250) : isAlt ? -300 : 300)
return (
<YStack
key={`${i}${tint}${tintAlt}`}
transition={reduceMotion ? null : 'superLazy'}
enterStyle={{
opacity: isOnHeroBelow ? 0.5 : 0,
}}
exitStyle={{
opacity: 0,
}}
opacity={isOnHeroBelow ? 0.5 : 0.8}
enterStyle={{ opacity: 0.5 }}
exitStyle={{ opacity: 0 }}
opacity={0.5}
overflow="hidden"
height="100vh"
maxH={650}
@@ -102,10 +47,9 @@ export const HomeGlow = memo(() => {
position="absolute"
t={0}
l={`calc(50vw - 500px)`}
x={x}
y={isOnHeroBelow ? 350 : yRand + 250}
scale={scale * (isAlt ? 0.5 : 1) * scales[tintIndex][i]}
scaleX={isOpposing ? 1 : 1}
x={i === 1 ? -249 : 251}
y={350}
scale={2 * scale}
>
<YStack
fullscreen
@@ -121,7 +65,7 @@ export const HomeGlow = memo(() => {
</YStack>
)
})
}, [scale, tint, tints, reduceMotion])
}, [atHero, tint, tintAlt, tintIndex, tints, reduceMotion])
return (
<YStack
@@ -133,16 +77,11 @@ export const HomeGlow = memo(() => {
key={0}
z={0}
x={0}
y={scrollTop}
opacity={0.3}
y={-100}
opacity={0.24}
// keep the whole glow group on its own compositor layer so page scroll
// moves a cached texture rather than repainting the gradients
style={{ willChange: 'transform' }}
{...(isOnHeroBelow && {
x: sectionIndex === 2 ? -xs : sectionIndex === 4 ? xs : 0,
y: -100,
opacity: 0.24,
})}
>
<AnimatePresence>{glows}</AnimatePresence>
</YStack>
@@ -3,6 +3,10 @@ import { H1, H2, H3, styled } from '@hanzo/gui'
export const HomeH1 = styled(H1, {
fontFamily: '$mono',
className: 'word-break-keep-all',
// Balance is how a title avoids a lone last word. Gluing the last two with a
// non-breaking space is the other answer, and on a narrow page it made a pair
// too long for the measure and the browser split it mid-word instead.
'$platform-web': { textWrap: 'balance' },
size: '$9',
mb: '$2',
color: '$color11',
@@ -31,7 +31,7 @@ import {
useIsomorphicLayoutEffect,
useMedia,
} from '@hanzo/gui'
import { Container, ContainerLarge } from '~/components/Containers'
import { ContainerLarge } from '~/components/Containers'
import { useIsIntersecting } from '~/hooks/useOnIntersecting'
import { useTransitionState } from '~/hooks/useTransitionState'
import favicon from '~/public/favicon.svg'
@@ -131,6 +131,7 @@ export const HomeResponsive = memo(() => {
const media = useMedia()
const [smIndex, setSmIndex] = useState(0)
const [gutter, setGutter] = useState(0)
const [width, setWidth] = useState(initialWidth)
const isSmall = initialWidth + Math.max(0, move) < 680
@@ -149,26 +150,27 @@ export const HomeResponsive = memo(() => {
prevMove.current = 0
}, [])
const scale = 0.7 - smIndex * 0.05
// The mock is a browser of a fixed width, so under sm it is scaled to the
// gutter box it stands in — from that box's left edge, which is where the
// heading starts.
const fit = media.sm && gutter ? Math.min(1, gutter / width) : 1
return (
<YStack ref={ref as any} y={0} mt={-80} position="relative">
<ContainerLarge position="relative">
<ResponsiveHeader />
<Spacer size="$6" $sm={{ size: '$0' }} />
<YStack height={browserHeight + 80} />
<YStack
height={(browserHeight + 80) * fit}
onLayout={(e) => setGutter(e.nativeEvent.layout.width)}
/>
<XStack
b={-20}
position="absolute"
z={1}
flex={1}
gap="$1"
// mostly keeping this to make sure we get a good ACID test of useMedia().sm
{...(media.sm && {
scale,
x: 150 - width / 2 - (smIndex ? (0.68 - scale) * 920 : 0),
y: -40,
})}
{...(fit !== 1 && { scale: fit, transformOrigin: 'left bottom' })}
>
<YStack
z={2}
@@ -187,21 +189,21 @@ export const HomeResponsive = memo(() => {
<Safari shouldLoad={hasInteracted} isSmall={isSmall} />
</YStack>
<Container z={1} position="absolute">
<XStack x={-10} $sm={{ display: 'none' }}>
{breakpoints.map((bp, i) => {
return (
<Marker
key={i}
onPress={handleMarkerPress}
active={i === 0 ? true : sizeI > i}
name={breakpoints[i].name}
l={breakpoints[i].at}
/>
)
})}
</XStack>
</Container>
{/* The ruler shares the mock's own box, so a marker at 500 stands
500px from the edge the mock starts at. */}
<XStack position="absolute" t={0} l={0} z={1} $sm={{ display: 'none' }}>
{breakpoints.map((bp, i) => {
return (
<Marker
key={i}
onPress={handleMarkerPress}
active={i === 0 ? true : sizeI > i}
name={bp.name}
l={bp.at}
/>
)
})}
</XStack>
</XStack>
<YStack
@@ -304,7 +306,6 @@ const SafariFrame = ({ children, ...props }: YStackProps) => {
rounded="$4"
borderColor="$borderColor"
borderWidth={1}
width="99%"
{...props}
>
{useMemo(() => children, [children])}
@@ -31,6 +31,7 @@ const Row = ({ children, ...props }: { children: any; 'aria-label': string }) =>
<XStack
role="group"
gap="$2"
$xs={{ gap: '$1' }}
p="$2"
self="center"
rounded="$10"
@@ -129,6 +130,14 @@ export const HomeThemes = memo(function HomeThemes() {
const width = 110
const scale = 0.38
// Where the row stands for a preview to sit under the demo. Asked of the
// layout: an arithmetic copy of the padding and the gap drifts from them, and
// it did — by one gap per stop, so the ninth sat a whole card off centre.
const stopAt = (node: HTMLElement, index: number) => {
const item = node.firstElementChild?.children[index] as HTMLElement | undefined
return item ? item.offsetLeft + (item.offsetWidth - node.clientWidth) / 2 : null
}
const scrollToIndex = useEvent((index: number, force = false) => {
const node = scrollView.current
const lock = getLock()
@@ -136,8 +145,8 @@ export const HomeThemes = memo(function HomeThemes() {
const isForced = force && (isReadyToAnimate || lock === null)
const shouldPrevent = !isReadyToAnimate && !isForced
if (!node || shouldPrevent) return
const left = width * index + width / 2
if (node.scrollLeft === left) return
const left = stopAt(node, index)
if (left === null || Math.abs(node.scrollLeft - left) < 1) return
node.scrollTo({ left, top: 0, behavior: 'smooth' })
})
@@ -193,52 +202,50 @@ export const HomeThemes = memo(function HomeThemes() {
}, [])}
<YStack my="$8" items="center" justify="center">
<XStack className="scroll-horizontal no-scrollbar">
<XStack px="$4" gap="$2">
<Row aria-label="Color scheme">
{(['light', 'dark'] as const).map((name) => {
const Icon = name === 'dark' ? Moon : Sun
return (
<ActiveCircle
key={name}
aria-label={name}
isActive={userScheme.value === name}
onPress={() => userScheme.set(name)}
>
<Icon size={15} color="$color" />
</ActiveCircle>
)
})}
</Row>
<Row aria-label="Accent">
{themes[0].map((color, i) => (
<XStack px="$4" gap="$2" width="100%" justify="center" flexWrap="wrap">
<Row aria-label="Color scheme">
{(['light', 'dark'] as const).map((name) => {
const Icon = name === 'dark' ? Moon : Sun
return (
<ActiveCircle
key={`${String(color)}${i}`}
aria-label={String(color)}
isActive={curColorI === i}
onPress={() => {
updateActiveI([i, curShadeI])
setAccent(tints, i)
}}
key={name}
aria-label={name}
isActive={userScheme.value === name}
onPress={() => userScheme.set(name)}
>
{/* Only the dot wears the stop. Its rings stay the page's own,
<Icon size={15} color="$color" />
</ActiveCircle>
)
})}
</Row>
<Row aria-label="Accent">
{themes[0].map((color, i) => (
<ActiveCircle
key={`${String(color)}${i}`}
aria-label={String(color)}
isActive={curColorI === i}
onPress={() => {
updateActiveI([i, curShadeI])
setAccent(tints, i)
}}
>
{/* Only the dot wears the stop. Its rings stay the page's own,
because white and black carry the ground's own ink, and a
ring in it says nothing on the scheme they belong to. */}
<Theme name={color}>
<Circle
size={16}
bg={
`$color${STOPS[String(color)]?.[userScheme.value] ?? 9}` as ColorTokens
}
borderWidth={1}
borderColor="$color"
/>
</Theme>
</ActiveCircle>
))}
</Row>
</XStack>
<Theme name={color}>
<Circle
size={16}
bg={
`$color${STOPS[String(color)]?.[userScheme.value] ?? 9}` as ColorTokens
}
borderWidth={1}
borderColor="$color"
/>
</Theme>
</ActiveCircle>
))}
</Row>
</XStack>
<YStack
@@ -257,14 +264,14 @@ export const HomeThemes = memo(function HomeThemes() {
if (scrollLock === 'animate' || scrollLock === 'shouldAnimate') {
return
}
const scrollX = Math.max(0, e.target.scrollLeft)
// Match the formula in scrollToIndex: left = width * index + width / 2
const itemI = Math.max(
0,
Math.min(
Math.round((scrollX - width / 2) / width),
themeCombos.length - 1
)
const node = e.target as HTMLElement
const items = [...node.firstElementChild!.children] as HTMLElement[]
const mid = node.scrollLeft + node.clientWidth / 2
const off = (el: HTMLElement) =>
Math.abs(el.offsetLeft + el.offsetWidth / 2 - mid)
const itemI = items.reduce(
(best, el, i) => (off(el) < off(items[best]) ? i : best),
0
)
const [n1, n2] = flatToSplit(itemI)
const [c1, c2] = activeI
@@ -279,8 +286,7 @@ export const HomeThemes = memo(function HomeThemes() {
justify="center"
gap="$5"
position="relative"
px={`calc(50vw + 30px)`}
x={-45 - 30}
px="50%"
>
{useMemo(() => {
return themeCombos.map((name, i) => {