diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx index 18e64a2a105..b142adc61ce 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx @@ -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 = new Set() @@ -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 @@ -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} diff --git a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx index 580f7484de2..4160191677d 100644 --- a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx @@ -90,6 +90,19 @@ function click(label: string) { act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))) } +function announcement(): HTMLElement { + const element = host.querySelector('[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 @@ -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' @@ -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( - + + Read update + + } + > ) @@ -136,7 +159,9 @@ 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()) @@ -144,7 +169,9 @@ describe('NavbarShell menu positioning and scroll containment', () => { 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) }) @@ -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('[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)) + }) +}) diff --git a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx index dff811a914d..f3f4c4d94c2 100644 --- a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx @@ -22,6 +22,7 @@ interface NavbarFrostContextValue { } const NavbarFrostContext = createContext(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 { @@ -29,6 +30,7 @@ export function useNavbarFrost(): NavbarFrostContextValue | null { } interface NavbarShellProps { + announcement?: ReactNode children: ReactNode } @@ -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. @@ -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 @@ -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(null) const headerRef = useRef(null) + const announcementRef = useRef(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 @@ -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 @@ -166,7 +199,13 @@ export function NavbarShell({ children }: NavbarShellProps) {
- + }>