Skip to content

Commit e21130b

Browse files
authored
fix(landing): stabilize announcement scrolling and mobile previews (#7852)
1 parent e94f069 commit e21130b

6 files changed

Lines changed: 182 additions & 43 deletions

File tree

apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ const FIT_MAX_ZOOM = 1
6767
* just fits a 1750px frame and only overflows on narrower ones.
6868
*/
6969
const FIT_MIN_ZOOM = 0.64
70+
/** Static overviews must fit on phones, where cropping can leave no visible cards. */
71+
const REDUCED_MOTION_FIT_MIN_ZOOM = 0.05
7072
const FIT_DURATION_MS = 600
7173
const EMPTY_IDS: ReadonlySet<string> = new Set()
7274

@@ -616,7 +618,7 @@ function ProductionWorkflowCanvas({
616618
const zoom = Math.min(
617619
FIT_MAX_ZOOM,
618620
Math.max(
619-
FIT_MIN_ZOOM,
621+
reducedMotion ? REDUCED_MOTION_FIT_MIN_ZOOM : FIT_MIN_ZOOM,
620622
Math.min(
621623
(width - 2 * FIT_PADDING_PX) / bounds.width,
622624
(height - 2 * FIT_PADDING_PX) / bounds.height
@@ -747,7 +749,7 @@ function ProductionWorkflowCanvas({
747749
onNodesChange={handleNodesChange}
748750
nodeTypes={NODE_TYPES}
749751
edgeTypes={EDGE_TYPES}
750-
minZoom={MIN_ZOOM}
752+
minZoom={scripted && reducedMotion ? REDUCED_MOTION_FIT_MIN_ZOOM : MIN_ZOOM}
751753
maxZoom={MAX_ZOOM}
752754
defaultViewport={{ x: 0, y: 48, zoom: FOCUSED_NODE_MIN_ZOOM }}
753755
panOnDrag={interactive}

apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,19 @@ function click(label: string) {
9090
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })))
9191
}
9292

93+
function announcement(): HTMLElement {
94+
const element = host.querySelector<HTMLElement>('[data-test-announcement]')?.parentElement
95+
if (!element) throw new Error('Missing announcement')
96+
return element
97+
}
98+
99+
function scrollTo(position: number) {
100+
act(() => {
101+
host.scrollTop = position
102+
host.dispatchEvent(new Event('scroll'))
103+
})
104+
}
105+
93106
function unmount() {
94107
act(() => root.unmount())
95108
mounted = false
@@ -103,8 +116,10 @@ beforeEach(() => {
103116
vi.stubGlobal('ResizeObserver', ControlledResizeObserver)
104117
vi.stubGlobal('IntersectionObserver', ControlledIntersectionObserver)
105118
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
106-
return new DOMRect(0, 0, 1440, this.tagName === 'HEADER' ? headerHeight : 0)
119+
const height = this.tagName === 'HEADER' ? headerHeight : 32
120+
return new DOMRect(0, 0, 1440, height)
107121
})
122+
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(32)
108123

109124
host = document.createElement('div')
110125
host.style.overflowY = 'scroll'
@@ -114,13 +129,21 @@ beforeEach(() => {
114129
Object.defineProperties(host, {
115130
offsetWidth: { value: 1440 },
116131
clientWidth: { value: 1420 },
132+
scrollHeight: { value: 2000 },
133+
clientHeight: { value: 800 },
117134
})
118135
document.body.append(host)
119136
root = createRoot(host)
120137
mounted = true
121138
act(() => {
122139
root.render(
123-
<NavbarShell>
140+
<NavbarShell
141+
announcement={
142+
<a href='/blog/update' data-test-announcement>
143+
Read update
144+
</a>
145+
}
146+
>
124147
<MenuControls />
125148
</NavbarShell>
126149
)
@@ -136,15 +159,19 @@ afterEach(() => {
136159

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

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

147-
expect(header().style.getPropertyValue('--landing-header-height')).toBe('76px')
172+
expect(header().style.getPropertyValue('--landing-header-height')).toBe(
173+
'calc(76px - var(--landing-announcement-offset, 0px))'
174+
)
148175
expect(host.style.scrollPaddingTop).toBe('76px')
149176
expect(host.scrollTop).toBe(320)
150177
})
@@ -196,3 +223,82 @@ describe('NavbarShell menu positioning and scroll containment', () => {
196223
expect(host.style.paddingRight).toBe('12px')
197224
})
198225
})
226+
227+
describe('NavbarShell announcement scroll behavior', () => {
228+
it('hides on downward scroll and restores on upward scroll without changing the scroll position', () => {
229+
expect(announcement().hasAttribute('inert')).toBe(false)
230+
231+
scrollTo(400)
232+
233+
expect(announcement().hasAttribute('inert')).toBe(true)
234+
expect(announcement().getAttribute('aria-hidden')).toBe('true')
235+
expect(host.style.scrollPaddingTop).toBe('104px')
236+
expect(host.scrollTop).toBe(400)
237+
238+
scrollTo(380)
239+
240+
expect(announcement().hasAttribute('inert')).toBe(false)
241+
expect(host.style.scrollPaddingTop).toBe('104px')
242+
expect(host.scrollTop).toBe(380)
243+
})
244+
245+
it('ignores small direction changes but accumulates slow scrolling', () => {
246+
scrollTo(324)
247+
expect(announcement().hasAttribute('inert')).toBe(false)
248+
scrollTo(329)
249+
expect(announcement().hasAttribute('inert')).toBe(true)
250+
scrollTo(326)
251+
expect(announcement().hasAttribute('inert')).toBe(true)
252+
scrollTo(320)
253+
expect(announcement().hasAttribute('inert')).toBe(false)
254+
})
255+
256+
it('keeps the banner visible near the top and ignores overscroll bounce at both ends', () => {
257+
scrollTo(400)
258+
scrollTo(-30)
259+
expect(announcement().hasAttribute('inert')).toBe(false)
260+
scrollTo(10)
261+
expect(announcement().hasAttribute('inert')).toBe(false)
262+
263+
scrollTo(1200)
264+
scrollTo(1250)
265+
scrollTo(1200)
266+
expect(announcement().hasAttribute('inert')).toBe(true)
267+
scrollTo(1180)
268+
expect(announcement().hasAttribute('inert')).toBe(false)
269+
})
270+
271+
it('keeps the header stationary while a navigation menu is open', () => {
272+
scrollTo(400)
273+
click('Open mobile')
274+
scrollTo(300)
275+
expect(announcement().hasAttribute('inert')).toBe(true)
276+
expect(host.style.scrollPaddingTop).toBe('104px')
277+
278+
click('Close mobile')
279+
scrollTo(280)
280+
expect(announcement().hasAttribute('inert')).toBe(false)
281+
})
282+
283+
it('does not hide a focused announcement link', () => {
284+
host.querySelector<HTMLElement>('[data-test-announcement]')?.focus()
285+
scrollTo(400)
286+
expect(announcement().hasAttribute('inert')).toBe(false)
287+
})
288+
289+
it('restores the full header if native focus scrolling reaches the top while a menu is open', () => {
290+
scrollTo(400)
291+
click('Open mobile')
292+
scrollTo(0)
293+
294+
expect(announcement().hasAttribute('inert')).toBe(false)
295+
expect(host.style.scrollPaddingTop).toBe('104px')
296+
expect(host.style.overflowY).toBe('hidden')
297+
})
298+
299+
it('removes the scroll listener when the shell unmounts', () => {
300+
const removeListener = vi.spyOn(host, 'removeEventListener')
301+
unmount()
302+
expect(removeListener).toHaveBeenCalledWith('scroll', expect.any(Function))
303+
})
304+
})

apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ interface NavbarFrostContextValue {
2222
}
2323

2424
const NavbarFrostContext = createContext<NavbarFrostContextValue | null>(null)
25+
const SCROLL_DIRECTION_THRESHOLD = 8
2526

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

3132
interface NavbarShellProps {
33+
announcement?: ReactNode
3234
children: ReactNode
3335
}
3436

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

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

9097
const previousScrollPaddingTop = scrollPort.style.scrollPaddingTop
91-
let previousHeight = 0
9298
const updateHeight = () => {
9399
const height = header.getBoundingClientRect().height
94-
if (height === previousHeight) return
95-
previousHeight = height
96-
header.style.setProperty('--landing-header-height', `${height}px`)
100+
const announcementHeight = announcementRef.current?.getBoundingClientRect().height ?? 0
101+
header.style.setProperty('--landing-announcement-height', `${announcementHeight}px`)
102+
header.style.setProperty(
103+
'--landing-header-height',
104+
`calc(${height}px - var(--landing-announcement-offset, 0px))`
105+
)
97106
scrollPort.style.scrollPaddingTop = `${height}px`
98107
}
99108

100109
updateHeight()
101110
const observer = new ResizeObserver(updateHeight)
102111
observer.observe(header)
112+
if (announcementRef.current) observer.observe(announcementRef.current)
103113
return () => {
104114
observer.disconnect()
105115
scrollPort.style.scrollPaddingTop = previousScrollPaddingTop
106116
}
107117
}, [])
108118

119+
useEffect(() => {
120+
const scrollPort = sentinelRef.current?.parentElement
121+
const banner = announcementRef.current
122+
if (!scrollPort || !banner) return
123+
124+
const scrollPosition = () =>
125+
Math.max(0, Math.min(scrollPort.scrollTop, scrollPort.scrollHeight - scrollPort.clientHeight))
126+
let previousPosition = scrollPosition()
127+
const onScroll = () => {
128+
const position = scrollPosition()
129+
const delta = position - previousPosition
130+
const nearTop = position <= banner.offsetHeight
131+
if (!nearTop && (menuOpen || Math.abs(delta) < SCROLL_DIRECTION_THRESHOLD)) return
132+
133+
previousPosition = position
134+
if (banner.contains(document.activeElement)) return
135+
setAnnouncementHidden(!nearTop && delta > 0)
136+
}
137+
138+
scrollPort.addEventListener('scroll', onScroll, { passive: true })
139+
return () => scrollPort.removeEventListener('scroll', onScroll)
140+
}, [menuOpen])
141+
109142
useEffect(() => {
110143
const sentinel = sentinelRef.current
111144
if (!sentinel) return
@@ -166,7 +199,13 @@ export function NavbarShell({ children }: NavbarShellProps) {
166199
<header
167200
ref={headerRef}
168201
data-landing-header
169-
className='sticky top-0 z-50 [--landing-header-height:calc(1.95rem_+_62px)]'
202+
className={cn(
203+
'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',
204+
announcementHidden
205+
? '-top-[var(--landing-announcement-height)] [--landing-announcement-offset:var(--landing-announcement-height)]'
206+
: 'top-0 [--landing-announcement-offset:0px]',
207+
menuOpen && 'transition-none'
208+
)}
170209
>
171210
<div
172211
aria-hidden='true'
@@ -175,6 +214,11 @@ export function NavbarShell({ children }: NavbarShellProps) {
175214
scrolled || menuOpen ? NAVBAR_GLASS_SURFACE : 'bg-[var(--bg)]'
176215
)}
177216
/>
217+
{announcement && (
218+
<div ref={announcementRef} inert={announcementHidden} aria-hidden={announcementHidden}>
219+
{announcement}
220+
</div>
221+
)}
178222
{children}
179223
</header>
180224
<div

apps/sim/app/(landing)/components/navbar/navbar.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ interface NavbarProps {
3434

3535
export function Navbar({ stars }: NavbarProps) {
3636
return (
37-
<NavbarShell>
38-
<AnnouncementBanner />
37+
<NavbarShell announcement={<AnnouncementBanner />}>
3938
<nav
4039
aria-label='Primary navigation'
4140
itemScope

apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.test.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ let root: Root
4646

4747
beforeEach(() => {
4848
resizeObserver = null
49-
vi.stubGlobal('CSS', { supports: vi.fn(() => true) })
5049
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
5150
container = document.createElement('div')
5251
document.body.appendChild(container)
@@ -115,14 +114,11 @@ describe('calculateFitScale', () => {
115114

116115
describe('ResponsiveDesignStage', () => {
117116
it.each([
118-
{ width: 280, height: 400, supportsZoom: true },
119-
{ width: 280, height: 400, supportsZoom: false },
120-
{ width: 350, height: 340, supportsZoom: true },
121-
{ width: 350, height: 340, supportsZoom: false },
117+
{ width: 280, height: 400 },
118+
{ width: 350, height: 340 },
122119
])(
123-
'fits an uncapped $width × $height stage with zoom support: $supportsZoom',
124-
({ width, height, supportsZoom }) => {
125-
vi.stubGlobal('CSS', { supports: vi.fn(() => supportsZoom) })
120+
'scales text and layout together in an uncapped $width × $height stage',
121+
({ width, height }) => {
126122
act(() => {
127123
root.render(
128124
createElement(
@@ -140,13 +136,13 @@ describe('ResponsiveDesignStage', () => {
140136
const observer = resizeObserver
141137

142138
act(() => observer.deliver(width * 2, height * 1.5))
143-
expect(surface.style.zoom).toBe(supportsZoom ? '1.5' : '1')
144-
expect(surface.style.transform).toBe(supportsZoom ? '' : 'scale(1.5)')
139+
expect(surface.style.getPropertyValue('zoom')).toBe('')
140+
expect(surface.style.transform).toBe('scale(1.5)')
145141
expect(surface.style.opacity).toBe('1')
146142

147143
act(() => observer.deliver(width / 2, height * 2))
148-
expect(surface.style.zoom).toBe(supportsZoom ? '0.5' : '1')
149-
expect(surface.style.transform).toBe(supportsZoom ? '' : 'scale(0.5)')
144+
expect(surface.style.getPropertyValue('zoom')).toBe('')
145+
expect(surface.style.transform).toBe('scale(0.5)')
150146
expect(surface.style.opacity).toBe('1')
151147
}
152148
)
@@ -170,13 +166,13 @@ describe('ResponsiveDesignStage', () => {
170166

171167
act(() => observer.deliver(500, 250))
172168
expect(surface.style.opacity).toBe('1')
173-
expect(surface.style.zoom).toBe('0.5')
169+
expect(surface.style.transform).toBe('scale(0.5)')
174170

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

178174
act(() => observer.deliver(500, 250))
179175
expect(surface.style.opacity).toBe('1')
180-
expect(surface.style.zoom).toBe('0.5')
176+
expect(surface.style.transform).toBe('scale(0.5)')
181177
})
182178
})

0 commit comments

Comments
 (0)