Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ const FIT_MAX_ZOOM = 1
* just fits a 1750px frame and only overflows on narrower ones.
*/
const FIT_MIN_ZOOM = 0.64
/** Static overviews must fit on phones, where cropping can leave no visible cards. */
const REDUCED_MOTION_FIT_MIN_ZOOM = 0.05
const FIT_DURATION_MS = 600
const EMPTY_IDS: ReadonlySet<string> = new Set()

Expand Down Expand Up @@ -616,7 +618,7 @@ function ProductionWorkflowCanvas({
const zoom = Math.min(
FIT_MAX_ZOOM,
Math.max(
FIT_MIN_ZOOM,
reducedMotion ? REDUCED_MOTION_FIT_MIN_ZOOM : FIT_MIN_ZOOM,
Math.min(
(width - 2 * FIT_PADDING_PX) / bounds.width,
(height - 2 * FIT_PADDING_PX) / bounds.height
Expand Down Expand Up @@ -747,7 +749,7 @@ function ProductionWorkflowCanvas({
onNodesChange={handleNodesChange}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
minZoom={MIN_ZOOM}
minZoom={scripted && reducedMotion ? REDUCED_MOTION_FIT_MIN_ZOOM : MIN_ZOOM}
maxZoom={MAX_ZOOM}
defaultViewport={{ x: 0, y: 48, zoom: FOCUSED_NODE_MIN_ZOOM }}
panOnDrag={interactive}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ function click(label: string) {
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })))
}

function announcement(): HTMLElement {
const element = host.querySelector<HTMLElement>('[data-test-announcement]')?.parentElement
if (!element) throw new Error('Missing announcement')
return element
}

function scrollTo(position: number) {
act(() => {
host.scrollTop = position
host.dispatchEvent(new Event('scroll'))
})
}

function unmount() {
act(() => root.unmount())
mounted = false
Expand All @@ -103,8 +116,10 @@ beforeEach(() => {
vi.stubGlobal('ResizeObserver', ControlledResizeObserver)
vi.stubGlobal('IntersectionObserver', ControlledIntersectionObserver)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
return new DOMRect(0, 0, 1440, this.tagName === 'HEADER' ? headerHeight : 0)
const height = this.tagName === 'HEADER' ? headerHeight : 32
return new DOMRect(0, 0, 1440, height)
})
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(32)

host = document.createElement('div')
host.style.overflowY = 'scroll'
Expand All @@ -114,13 +129,21 @@ beforeEach(() => {
Object.defineProperties(host, {
offsetWidth: { value: 1440 },
clientWidth: { value: 1420 },
scrollHeight: { value: 2000 },
clientHeight: { value: 800 },
})
document.body.append(host)
root = createRoot(host)
mounted = true
act(() => {
root.render(
<NavbarShell>
<NavbarShell
announcement={
<a href='/blog/update' data-test-announcement>
Read update
</a>
}
>
<MenuControls />
</NavbarShell>
)
Expand All @@ -136,15 +159,19 @@ afterEach(() => {

describe('NavbarShell menu positioning and scroll containment', () => {
it('publishes the current header height before a resize and updates it when header content changes', () => {
expect(header().style.getPropertyValue('--landing-header-height')).toBe('104px')
expect(header().style.getPropertyValue('--landing-header-height')).toBe(
'calc(104px - var(--landing-announcement-offset, 0px))'
)
expect(host.style.scrollPaddingTop).toBe('104px')
expect(resizeObservers).toHaveLength(1)
expect(resizeObservers[0].observe).toHaveBeenCalledWith(header())

headerHeight = 76
act(() => resizeObservers[0].resize(header()))

expect(header().style.getPropertyValue('--landing-header-height')).toBe('76px')
expect(header().style.getPropertyValue('--landing-header-height')).toBe(
'calc(76px - var(--landing-announcement-offset, 0px))'
)
expect(host.style.scrollPaddingTop).toBe('76px')
expect(host.scrollTop).toBe(320)
})
Expand Down Expand Up @@ -196,3 +223,82 @@ describe('NavbarShell menu positioning and scroll containment', () => {
expect(host.style.paddingRight).toBe('12px')
})
})

describe('NavbarShell announcement scroll behavior', () => {
it('hides on downward scroll and restores on upward scroll without changing the scroll position', () => {
expect(announcement().hasAttribute('inert')).toBe(false)

scrollTo(400)

expect(announcement().hasAttribute('inert')).toBe(true)
expect(announcement().getAttribute('aria-hidden')).toBe('true')
expect(host.style.scrollPaddingTop).toBe('104px')
expect(host.scrollTop).toBe(400)

scrollTo(380)

expect(announcement().hasAttribute('inert')).toBe(false)
expect(host.style.scrollPaddingTop).toBe('104px')
expect(host.scrollTop).toBe(380)
})

it('ignores small direction changes but accumulates slow scrolling', () => {
scrollTo(324)
expect(announcement().hasAttribute('inert')).toBe(false)
scrollTo(329)
expect(announcement().hasAttribute('inert')).toBe(true)
scrollTo(326)
expect(announcement().hasAttribute('inert')).toBe(true)
scrollTo(320)
expect(announcement().hasAttribute('inert')).toBe(false)
})

it('keeps the banner visible near the top and ignores overscroll bounce at both ends', () => {
scrollTo(400)
scrollTo(-30)
expect(announcement().hasAttribute('inert')).toBe(false)
scrollTo(10)
expect(announcement().hasAttribute('inert')).toBe(false)

scrollTo(1200)
scrollTo(1250)
scrollTo(1200)
expect(announcement().hasAttribute('inert')).toBe(true)
scrollTo(1180)
expect(announcement().hasAttribute('inert')).toBe(false)
})

it('keeps the header stationary while a navigation menu is open', () => {
scrollTo(400)
click('Open mobile')
scrollTo(300)
expect(announcement().hasAttribute('inert')).toBe(true)
expect(host.style.scrollPaddingTop).toBe('104px')

click('Close mobile')
scrollTo(280)
expect(announcement().hasAttribute('inert')).toBe(false)
})

it('does not hide a focused announcement link', () => {
host.querySelector<HTMLElement>('[data-test-announcement]')?.focus()
scrollTo(400)
expect(announcement().hasAttribute('inert')).toBe(false)
})

it('restores the full header if native focus scrolling reaches the top while a menu is open', () => {
scrollTo(400)
click('Open mobile')
scrollTo(0)

expect(announcement().hasAttribute('inert')).toBe(false)
expect(host.style.scrollPaddingTop).toBe('104px')
expect(host.style.overflowY).toBe('hidden')
})

it('removes the scroll listener when the shell unmounts', () => {
const removeListener = vi.spyOn(host, 'removeEventListener')
unmount()
expect(removeListener).toHaveBeenCalledWith('scroll', expect.any(Function))
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ interface NavbarFrostContextValue {
}

const NavbarFrostContext = createContext<NavbarFrostContextValue | null>(null)
const SCROLL_DIRECTION_THRESHOLD = 8

/** Lets each nav surface report its open state so the shell can coordinate shared effects. */
export function useNavbarFrost(): NavbarFrostContextValue | null {
return use(NavbarFrostContext)
}

interface NavbarShellProps {
announcement?: ReactNode
children: ReactNode
}

Expand All @@ -39,8 +41,7 @@ interface NavbarShellProps {
* At the very top the bar uses the same solid canvas token as the hero, so it is
* visually seamless while still preventing route content from painting through
* the sticky header. A 1px sentinel at the top of the landing shell's internal
* scroll port is watched by an {@link IntersectionObserver} - no scroll listener
* and no per-frame work. Past that point the bar gains the shared
* scroll port is watched by an {@link IntersectionObserver}. Past that point the bar gains the shared
* {@link NAVBAR_GLASS_SURFACE} (`--bg` at 92% via `color-mix` plus a strong 40px
* backdrop blur) - a white/glass surface built entirely from the platform's
* light tokens, not invented colors.
Expand All @@ -51,8 +52,12 @@ interface NavbarShellProps {
* while the fill still fades, so the frost appears smoothly without the jitter.
*
* The measured header height anchors the desktop panel and bounds the mobile
* sheet, including changes to the announcement strip or text sizing. The same
* height offsets native page and hash scrolling inside the landing scroll port.
* sheet, including changes to the announcement strip or text sizing. Native
* page and hash scrolling reserve the full height so changing banner visibility
* does not move the scroll anchor and leaves room for the banner to return.
* Scrolling down slides the announcement above the viewport; scrolling up
* restores it. Moving the sticky inset preserves document flow and scroll
* position. Menu offsets use only the visible portion of the header.
*
* Both navigation surfaces report open state through {@link NavbarFrostContext}.
* While either is open, the shell locks its actual scroll port, preserves the
Expand All @@ -75,10 +80,12 @@ interface NavbarShellProps {
* Only this shell hydrates; the nav content is server-rendered and passed through
* as {@link children}, so the wordmark and links stay zero-hydration and crawlable.
*/
export function NavbarShell({ children }: NavbarShellProps) {
export function NavbarShell({ announcement, children }: NavbarShellProps) {
const sentinelRef = useRef<HTMLDivElement>(null)
const headerRef = useRef<HTMLElement>(null)
const announcementRef = useRef<HTMLDivElement>(null)
const [scrolled, setScrolled] = useState(false)
const [announcementHidden, setAnnouncementHidden] = useState(false)
const [menuOpenBySource, setMenuOpenBySource] = useState({ desktop: false, mobile: false })
const menuOpen = menuOpenBySource.desktop || menuOpenBySource.mobile

Expand All @@ -88,24 +95,50 @@ export function NavbarShell({ children }: NavbarShellProps) {
if (!header || !scrollPort) return

const previousScrollPaddingTop = scrollPort.style.scrollPaddingTop
let previousHeight = 0
const updateHeight = () => {
const height = header.getBoundingClientRect().height
if (height === previousHeight) return
previousHeight = height
header.style.setProperty('--landing-header-height', `${height}px`)
const announcementHeight = announcementRef.current?.getBoundingClientRect().height ?? 0
header.style.setProperty('--landing-announcement-height', `${announcementHeight}px`)
header.style.setProperty(
'--landing-header-height',
`calc(${height}px - var(--landing-announcement-offset, 0px))`
)
scrollPort.style.scrollPaddingTop = `${height}px`
}

updateHeight()
const observer = new ResizeObserver(updateHeight)
observer.observe(header)
if (announcementRef.current) observer.observe(announcementRef.current)
return () => {
observer.disconnect()
scrollPort.style.scrollPaddingTop = previousScrollPaddingTop
}
}, [])

useEffect(() => {
const scrollPort = sentinelRef.current?.parentElement
const banner = announcementRef.current
if (!scrollPort || !banner) return

const scrollPosition = () =>
Math.max(0, Math.min(scrollPort.scrollTop, scrollPort.scrollHeight - scrollPort.clientHeight))
let previousPosition = scrollPosition()
const onScroll = () => {
const position = scrollPosition()
const delta = position - previousPosition
const nearTop = position <= banner.offsetHeight
if (!nearTop && (menuOpen || Math.abs(delta) < SCROLL_DIRECTION_THRESHOLD)) return

previousPosition = position
if (banner.contains(document.activeElement)) return
setAnnouncementHidden(!nearTop && delta > 0)
}

scrollPort.addEventListener('scroll', onScroll, { passive: true })
return () => scrollPort.removeEventListener('scroll', onScroll)
}, [menuOpen])

useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
Expand Down Expand Up @@ -166,7 +199,13 @@ export function NavbarShell({ children }: NavbarShellProps) {
<header
ref={headerRef}
data-landing-header
className='sticky top-0 z-50 [--landing-header-height:calc(1.95rem_+_62px)]'
className={cn(
'sticky z-50 transition-[top] duration-200 ease-out [--landing-announcement-height:1.95rem] [--landing-header-height:calc(1.95rem_+_62px)] motion-reduce:transition-none',
announcementHidden
? '-top-[var(--landing-announcement-height)] [--landing-announcement-offset:var(--landing-announcement-height)]'
: 'top-0 [--landing-announcement-offset:0px]',
menuOpen && 'transition-none'
)}
>
<div
aria-hidden='true'
Expand All @@ -175,6 +214,11 @@ export function NavbarShell({ children }: NavbarShellProps) {
scrolled || menuOpen ? NAVBAR_GLASS_SURFACE : 'bg-[var(--bg)]'
)}
/>
{announcement && (
<div ref={announcementRef} inert={announcementHidden} aria-hidden={announcementHidden}>
{announcement}
</div>
)}
{children}
</header>
<div
Expand Down
3 changes: 1 addition & 2 deletions apps/sim/app/(landing)/components/navbar/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ interface NavbarProps {

export function Navbar({ stars }: NavbarProps) {
return (
<NavbarShell>
<AnnouncementBanner />
<NavbarShell announcement={<AnnouncementBanner />}>
<nav
aria-label='Primary navigation'
itemScope
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ let root: Root

beforeEach(() => {
resizeObserver = null
vi.stubGlobal('CSS', { supports: vi.fn(() => true) })
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
container = document.createElement('div')
document.body.appendChild(container)
Expand Down Expand Up @@ -115,14 +114,11 @@ describe('calculateFitScale', () => {

describe('ResponsiveDesignStage', () => {
it.each([
{ width: 280, height: 400, supportsZoom: true },
{ width: 280, height: 400, supportsZoom: false },
{ width: 350, height: 340, supportsZoom: true },
{ width: 350, height: 340, supportsZoom: false },
{ width: 280, height: 400 },
{ width: 350, height: 340 },
])(
'fits an uncapped $width × $height stage with zoom support: $supportsZoom',
({ width, height, supportsZoom }) => {
vi.stubGlobal('CSS', { supports: vi.fn(() => supportsZoom) })
'scales text and layout together in an uncapped $width × $height stage',
({ width, height }) => {
act(() => {
root.render(
createElement(
Expand All @@ -140,13 +136,13 @@ describe('ResponsiveDesignStage', () => {
const observer = resizeObserver

act(() => observer.deliver(width * 2, height * 1.5))
expect(surface.style.zoom).toBe(supportsZoom ? '1.5' : '1')
expect(surface.style.transform).toBe(supportsZoom ? '' : 'scale(1.5)')
expect(surface.style.getPropertyValue('zoom')).toBe('')
expect(surface.style.transform).toBe('scale(1.5)')
expect(surface.style.opacity).toBe('1')

act(() => observer.deliver(width / 2, height * 2))
expect(surface.style.zoom).toBe(supportsZoom ? '0.5' : '1')
expect(surface.style.transform).toBe(supportsZoom ? '' : 'scale(0.5)')
expect(surface.style.getPropertyValue('zoom')).toBe('')
expect(surface.style.transform).toBe('scale(0.5)')
expect(surface.style.opacity).toBe('1')
}
)
Expand All @@ -170,13 +166,13 @@ describe('ResponsiveDesignStage', () => {

act(() => observer.deliver(500, 250))
expect(surface.style.opacity).toBe('1')
expect(surface.style.zoom).toBe('0.5')
expect(surface.style.transform).toBe('scale(0.5)')

act(() => observer.deliver(0, 250))
expect(surface.style.opacity).toBe('0')

act(() => observer.deliver(500, 250))
expect(surface.style.opacity).toBe('1')
expect(surface.style.zoom).toBe('0.5')
expect(surface.style.transform).toBe('scale(0.5)')
})
})
Loading
Loading