From cff92ddeed396f1477ddd501974295de19ac2df9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 16:46:07 -0700 Subject: [PATCH 1/2] fix(landing): prevent theme flashes and sharpen footer animation --- .../footer-wordmark-loop.test.tsx | 34 +++++++++- .../footer-wordmark-loop.tsx | 37 +++++----- .../_shell/providers/theme-provider.test.tsx | 67 +++++++++++++++++++ apps/sim/lib/core/utils/theme.test.ts | 6 +- apps/sim/lib/core/utils/theme.ts | 15 +---- 5 files changed, 125 insertions(+), 34 deletions(-) diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx index a39fb279919..4f3673ca9c4 100644 --- a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx +++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx @@ -15,6 +15,8 @@ const CYCLE_MS = 17_100 let pending: FrameRequestCallback[] = [] let clock = 0 +let reducedMotion = false +let onMotionPreference: (() => void) | undefined let root: Root | null = null let host: HTMLDivElement | null = null @@ -40,15 +42,23 @@ beforeEach(() => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true pending = [] clock = 0 + reducedMotion = false + onMotionPreference = undefined const stubs = { requestAnimationFrame: (cb: FrameRequestCallback) => pending.push(cb), cancelAnimationFrame: () => { pending = [] }, matchMedia: () => ({ - matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, + get matches() { + return reducedMotion + }, + addEventListener: (_type: string, listener: () => void) => { + onMotionPreference = listener + }, + removeEventListener: () => { + onMotionPreference = undefined + }, }), } for (const [name, value] of Object.entries(stubs)) { @@ -80,6 +90,8 @@ describe('FooterWordmarkLoop', () => { expect(html).toContain('data-stage="wm" opacity="1"') expect(html).toContain('data-stage="orb" opacity="0"') expect(html).toContain('stdDeviation="0.55"') + expect(html).toContain('data-goo-group="" filter="none"') + expect(html).not.toContain(' { it('plays the master timeline: wordmark, orb, the seven shapes, orb, wordmark', () => { expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550') + expect(attr('[data-goo-group]', 'filter')).toBe('none') advanceTo(2700) expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000') expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000') expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000') + expect(attr('[data-goo-group]', 'filter')).toMatch(/^url\(#fwl-goo-/) advanceTo(3900) expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('1.0000') @@ -113,12 +127,26 @@ describe('FooterWordmarkLoop', () => { expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') expect(attr('[data-stage="thinking"]', 'opacity')).toBe('0.0000') expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550') + expect(attr('[data-goo-group]', 'filter')).toBe('none') advanceTo(CYCLE_MS + 2700) expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000') expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000') }) + it('returns to an unfiltered wordmark when reduced motion is enabled mid-morph', () => { + advanceTo(2700) + expect(attr('[data-goo-group]', 'filter')).toMatch(/^url\(#fwl-goo-/) + + reducedMotion = true + act(() => onMotionPreference?.()) + + expect(pending).toHaveLength(0) + expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') + expect(attr('[data-stage="orb"]', 'opacity')).toBe('0.0000') + expect(attr('[data-goo-group]', 'filter')).toBe('none') + }) + it('stops requesting frames on unmount', () => { advanceTo(500) act(() => root?.unmount()) diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx index f07058d2c1a..79c12819bb9 100644 --- a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx +++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx @@ -43,11 +43,9 @@ const ORB_BEAT = 450 /** Closing hold on the wordmark before the loop wraps back to the opening hold. */ const HOLD_LOGO_END = 1700 const TAIL = 200 -/** Goo blur while liquid (through the cycle) and while crisp (the wordmark). */ +/** Blur range for the filtered portion of the morph. */ const GOO_HI = 5 const GOO_LO = 0.55 -/** Post-threshold blur, about half a device pixel at the mark's largest size. */ -const EDGE_SMOOTHING = 0.16 /** * Shapes that restart from compact when they appear and play exactly one pulse * of this many ms (just under a loop, so the dots reach the edge without @@ -356,13 +354,19 @@ interface StageNode { key: StageKey } +interface GooFilterNodes { + blur: SVGFEGaussianBlurElement + group: SVGGElement + url: string +} + /** * Paints one frame of the choreography at `t` ms into the cycle by writing * SVG attributes directly - no React render per frame. */ function paintFrame( t: number, - blur: SVGFEGaussianBlurElement, + goo: GooFilterNodes, stages: StageNode[], anims: AnimatedNode[] ): void { @@ -412,7 +416,12 @@ function paintFrame( 1 - smooth(T_OUTRO_START, T_OUTRO_END, t) ) const deviation = round(GOO_LO + (GOO_HI - GOO_LO) * liquid) - if (blur.getAttribute('stdDeviation') !== deviation) blur.setAttribute('stdDeviation', deviation) + if (goo.blur.getAttribute('stdDeviation') !== deviation) { + goo.blur.setAttribute('stdDeviation', deviation) + } + /** Resting vectors retain native antialiasing; only the liquid morph needs raster filtering. */ + const filter = liquid > 0 ? goo.url : 'none' + if (goo.group.getAttribute('filter') !== filter) goo.group.setAttribute('filter', filter) } interface FooterWordmarkLoopProps { @@ -454,7 +463,9 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { const svg = svgRef.current if (!svg) return const blur = svg.querySelector('[data-goo]') - if (!blur) return + const group = svg.querySelector('[data-goo-group]') + if (!blur || !group) return + const goo: GooFilterNodes = { blur, group, url: `url(#${gooId})` } const stages: StageNode[] = Array.from( svg.querySelectorAll('[data-stage]'), @@ -475,7 +486,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { const tick = (now: number) => { if (previous !== null) elapsed += Math.min(now - previous, MAX_FRAME_STEP) previous = now - paintFrame(elapsed % CYCLE_MS, blur, stages, anims) + paintFrame(elapsed % CYCLE_MS, goo, stages, anims) frame = requestAnimationFrame(tick) } const play = () => { @@ -492,7 +503,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { if (reducedMotion?.matches) { pause() elapsed = 0 - paintFrame(0, blur, stages, anims) + paintFrame(0, goo, stages, anims) } else { play() } @@ -517,7 +528,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { observer?.disconnect() reducedMotion?.removeEventListener('change', onMotionPreference) } - }, []) + }, [gooId]) return (
@@ -545,11 +556,6 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 40 -19' result='goo' /> - {/* The threshold discards the rasterizer's edge coverage, so at the - resting blur the wordmark's edge fell inside a device pixel and - stair-stepped at the largest size. A sub-pixel blur after it - restores ordinary anti-aliasing without touching the melt. */} - @@ -575,7 +581,8 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { ({ mockUsePathname: vi.fn() })) vi.mock('next/navigation', () => ({ usePathname: mockUsePathname })) +import { syncThemeToNextThemes } from '@/lib/core/utils/theme' import { ThemeProvider } from '@/app/_shell/providers/theme-provider' let root: Root @@ -37,6 +38,15 @@ function render(pathname: string) { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + /** The global storage mock is not a native jsdom Storage instance. */ + vi.stubGlobal( + 'StorageEvent', + class extends window.StorageEvent { + constructor(type: string, init: StorageEventInit) { + super(type, { ...init, storageArea: null }) + } + } + ) stubDarkOs() localStorage.clear() document.documentElement.className = '' @@ -74,4 +84,61 @@ describe('ThemeProvider theme stores', () => { localStorage.setItem('sim-landing-theme', 'dark') expect(render('/login')).toContain('light') }) + + it.each(['/', '/blog', '/customers/example'])( + 'keeps %s light when account settings resolve dark', + (pathname) => { + localStorage.setItem('sim-theme', 'dark') + const classes = render(pathname) + expect(classes).toContain('light') + + act(() => syncThemeToNextThemes('dark')) + + expect(classes).toContain('light') + expect(classes).not.toContain('dark') + } + ) + + it('preserves the landing footer choice when account settings change', () => { + localStorage.setItem('sim-landing-theme', 'dark') + const classes = render('/workflows') + + act(() => syncThemeToNextThemes('light')) + + expect(classes).toContain('dark') + expect(localStorage.getItem('sim-landing-theme')).toBe('dark') + expect(localStorage.getItem('sim-theme')).toBe('light') + }) + + it('preserves the forced auth theme when account settings resolve', () => { + const classes = render('/login') + + act(() => syncThemeToNextThemes('dark')) + + expect(classes).toContain('light') + expect(classes).not.toContain('dark') + }) + + it('updates the workspace theme when account settings resolve', () => { + localStorage.setItem('sim-theme', 'light') + const classes = render('/workspace/ws-1/home') + expect(classes).toContain('light') + + act(() => syncThemeToNextThemes('dark')) + + expect(classes).toContain('dark') + expect(classes).not.toContain('light') + expect(document.documentElement.style.colorScheme).toBe('dark') + }) + + it('resolves the workspace system theme through the active provider', () => { + localStorage.setItem('sim-theme', 'light') + const classes = render('/workspace/ws-1/home') + + act(() => syncThemeToNextThemes('system')) + + expect(classes).toContain('dark') + expect(document.documentElement.style.colorScheme).toBe('dark') + expect(localStorage.getItem('sim-theme')).toBe('system') + }) }) diff --git a/apps/sim/lib/core/utils/theme.test.ts b/apps/sim/lib/core/utils/theme.test.ts index 2d374e74eac..a161f1779d1 100644 --- a/apps/sim/lib/core/utils/theme.test.ts +++ b/apps/sim/lib/core/utils/theme.test.ts @@ -25,7 +25,7 @@ describe('syncThemeToNextThemes', () => { expect(add).not.toHaveBeenCalled() }) - it('repairs the document class without emitting a redundant storage event', () => { + it('leaves document classes to the active theme provider', () => { localStorage.setItem('sim-theme', 'dark') document.documentElement.classList.add('light') const dispatchEvent = vi.spyOn(window, 'dispatchEvent') @@ -33,7 +33,7 @@ describe('syncThemeToNextThemes', () => { syncThemeToNextThemes('dark') expect(dispatchEvent).not.toHaveBeenCalled() - expect(document.documentElement.classList.contains('dark')).toBe(true) - expect(document.documentElement.classList.contains('light')).toBe(false) + expect(document.documentElement.classList.contains('light')).toBe(true) + expect(document.documentElement.classList.contains('dark')).toBe(false) }) }) diff --git a/apps/sim/lib/core/utils/theme.ts b/apps/sim/lib/core/utils/theme.ts index 29a77542d6e..80b00ef882a 100644 --- a/apps/sim/lib/core/utils/theme.ts +++ b/apps/sim/lib/core/utils/theme.ts @@ -5,6 +5,8 @@ /** * Updates the theme in next-themes by dispatching a storage event. * This works by updating localStorage and notifying next-themes of the change. + * The active provider owns document classes, including forced themes and the + * landing surface's independent preference. * @param theme - The desired theme ('system', 'light', or 'dark') */ export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') { @@ -24,17 +26,4 @@ export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') { }) ) } - - const root = document.documentElement - const appliedTheme = - theme === 'system' - ? window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light' - : theme - const oppositeTheme = appliedTheme === 'dark' ? 'light' : 'dark' - if (root.classList.contains(appliedTheme) && !root.classList.contains(oppositeTheme)) return - - root.classList.remove('light', 'dark') - root.classList.add(appliedTheme) } From dff8b99c30bd28508ef8fe24263c7d490ae5924f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 17:09:31 -0700 Subject: [PATCH 2/2] fix(landing): preserve the footer liquid morph --- .../footer-wordmark-loop.test.tsx | 47 +++++++++++++++---- .../footer-wordmark-loop.tsx | 31 ++++++------ 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx index 4f3673ca9c4..551079a0b80 100644 --- a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx +++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx @@ -89,8 +89,9 @@ describe('FooterWordmarkLoop', () => { expect(html).toContain('aria-hidden="true"') expect(html).toContain('data-stage="wm" opacity="1"') expect(html).toContain('data-stage="orb" opacity="0"') - expect(html).toContain('stdDeviation="0.55"') - expect(html).toContain('data-goo-group="" filter="none"') + expect(html).toContain('stdDeviation="0"') + expect(html).toMatch(/filter="url\(#fwl-goo-/) + expect(html).toContain('values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"') expect(html).not.toContain(' { it('plays the master timeline: wordmark, orb, the seven shapes, orb, wordmark', () => { expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') - expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550') - expect(attr('[data-goo-group]', 'filter')).toBe('none') + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) advanceTo(2700) expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000') expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000') expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000') - expect(attr('[data-goo-group]', 'filter')).toMatch(/^url\(#fwl-goo-/) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/) advanceTo(3900) expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('1.0000') @@ -126,17 +127,17 @@ describe('FooterWordmarkLoop', () => { advanceTo(16000) expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') expect(attr('[data-stage="thinking"]', 'opacity')).toBe('0.0000') - expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550') - expect(attr('[data-goo-group]', 'filter')).toBe('none') + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) advanceTo(CYCLE_MS + 2700) expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000') expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000') }) - it('returns to an unfiltered wordmark when reduced motion is enabled mid-morph', () => { + it('returns to an identity filter when reduced motion is enabled mid-morph', () => { advanceTo(2700) - expect(attr('[data-goo-group]', 'filter')).toMatch(/^url\(#fwl-goo-/) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/) reducedMotion = true act(() => onMotionPreference?.()) @@ -144,7 +145,33 @@ describe('FooterWordmarkLoop', () => { expect(pending).toHaveLength(0) expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') expect(attr('[data-stage="orb"]', 'opacity')).toBe('0.0000') - expect(attr('[data-goo-group]', 'filter')).toBe('none') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + }) + + it('eases the same filter to identity at both wordmark boundaries', () => { + advanceTo(1300) + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + + advanceTo(1301) + expect(Number(attr('[data-goo]', 'stdDeviation'))).toBeLessThan(0.001) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + + advanceTo(1800) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/) + + advanceTo(2500) + expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000') + + advanceTo(15199) + expect(Number(attr('[data-goo]', 'stdDeviation'))).toBeLessThan(0.001) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + + advanceTo(15200) + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + expect(host?.querySelector('feComposite')).toBeNull() }) it('stops requesting frames on unmount', () => { diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx index 79c12819bb9..f8d2d9c6688 100644 --- a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx +++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx @@ -356,8 +356,7 @@ interface StageNode { interface GooFilterNodes { blur: SVGFEGaussianBlurElement - group: SVGGElement - url: string + matrix: SVGFEColorMatrixElement } /** @@ -415,13 +414,17 @@ function paintFrame( smooth(T_LOGO_HOLD_END, T_INTRO_END, t), 1 - smooth(T_OUTRO_START, T_OUTRO_END, t) ) - const deviation = round(GOO_LO + (GOO_HI - GOO_LO) * liquid) + /** Ease the filter to identity at rest without overlaying the unfiltered shapes. */ + const strength = Math.min( + smooth(T_LOGO_HOLD_END, T_LOGO_HOLD_END + MORPH, t), + 1 - smooth(T_OUTRO_END - MORPH, T_OUTRO_END, t) + ) + const deviation = round((GOO_LO + (GOO_HI - GOO_LO) * liquid) * strength) if (goo.blur.getAttribute('stdDeviation') !== deviation) { goo.blur.setAttribute('stdDeviation', deviation) } - /** Resting vectors retain native antialiasing; only the liquid morph needs raster filtering. */ - const filter = liquid > 0 ? goo.url : 'none' - if (goo.group.getAttribute('filter') !== filter) goo.group.setAttribute('filter', filter) + const matrix = `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${round(1 + 39 * strength)} ${round(-19 * strength)}` + if (goo.matrix.getAttribute('values') !== matrix) goo.matrix.setAttribute('values', matrix) } interface FooterWordmarkLoopProps { @@ -463,9 +466,9 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { const svg = svgRef.current if (!svg) return const blur = svg.querySelector('[data-goo]') - const group = svg.querySelector('[data-goo-group]') - if (!blur || !group) return - const goo: GooFilterNodes = { blur, group, url: `url(#${gooId})` } + const matrix = svg.querySelector('[data-goo-matrix]') + if (!blur || !matrix) return + const goo: GooFilterNodes = { blur, matrix } const stages: StageNode[] = Array.from( svg.querySelectorAll('[data-stage]'), @@ -528,7 +531,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { observer?.disconnect() reducedMotion?.removeEventListener('change', onMotionPreference) } - }, [gooId]) + }, []) return (
@@ -547,13 +550,14 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { height='160%' colorInterpolationFilters='sRGB' > - + {/* A steep threshold: the melt between shapes keeps its liquid merges, but every edge resolves within a pixel, so the mark stays crisp at the cycle's full blur. */} @@ -581,8 +585,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {