Skip to content

Commit d3b2c5e

Browse files
authored
fix(landing): prevent theme flashes and sharpen footer animation (#7699)
* fix(landing): prevent theme flashes and sharpen footer animation * fix(landing): preserve the footer liquid morph
1 parent 768c389 commit d3b2c5e

5 files changed

Lines changed: 159 additions & 38 deletions

File tree

apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const CYCLE_MS = 17_100
1515

1616
let pending: FrameRequestCallback[] = []
1717
let clock = 0
18+
let reducedMotion = false
19+
let onMotionPreference: (() => void) | undefined
1820
let root: Root | null = null
1921
let host: HTMLDivElement | null = null
2022

@@ -40,15 +42,23 @@ beforeEach(() => {
4042
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
4143
pending = []
4244
clock = 0
45+
reducedMotion = false
46+
onMotionPreference = undefined
4347
const stubs = {
4448
requestAnimationFrame: (cb: FrameRequestCallback) => pending.push(cb),
4549
cancelAnimationFrame: () => {
4650
pending = []
4751
},
4852
matchMedia: () => ({
49-
matches: false,
50-
addEventListener: () => {},
51-
removeEventListener: () => {},
53+
get matches() {
54+
return reducedMotion
55+
},
56+
addEventListener: (_type: string, listener: () => void) => {
57+
onMotionPreference = listener
58+
},
59+
removeEventListener: () => {
60+
onMotionPreference = undefined
61+
},
5262
}),
5363
}
5464
for (const [name, value] of Object.entries(stubs)) {
@@ -79,7 +89,10 @@ describe('FooterWordmarkLoop', () => {
7989
expect(html).toContain('aria-hidden="true"')
8090
expect(html).toContain('data-stage="wm" opacity="1"')
8191
expect(html).toContain('data-stage="orb" opacity="0"')
82-
expect(html).toContain('stdDeviation="0.55"')
92+
expect(html).toContain('stdDeviation="0"')
93+
expect(html).toMatch(/filter="url\(#fwl-goo-/)
94+
expect(html).toContain('values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"')
95+
expect(html).not.toContain('<feGaussianBlur in="goo"')
8396
for (const shape of SHAPES) {
8497
expect(html).toContain(`data-stage="${shape}" opacity="0"`)
8598
}
@@ -90,12 +103,14 @@ describe('FooterWordmarkLoop', () => {
90103

91104
it('plays the master timeline: wordmark, orb, the seven shapes, orb, wordmark', () => {
92105
expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000')
93-
expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550')
106+
expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000')
107+
expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/)
94108

95109
advanceTo(2700)
96110
expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000')
97111
expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000')
98112
expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000')
113+
expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/)
99114

100115
advanceTo(3900)
101116
expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('1.0000')
@@ -112,13 +127,53 @@ describe('FooterWordmarkLoop', () => {
112127
advanceTo(16000)
113128
expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000')
114129
expect(attr('[data-stage="thinking"]', 'opacity')).toBe('0.0000')
115-
expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550')
130+
expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000')
131+
expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/)
116132

117133
advanceTo(CYCLE_MS + 2700)
118134
expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000')
119135
expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000')
120136
})
121137

138+
it('returns to an identity filter when reduced motion is enabled mid-morph', () => {
139+
advanceTo(2700)
140+
expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/)
141+
142+
reducedMotion = true
143+
act(() => onMotionPreference?.())
144+
145+
expect(pending).toHaveLength(0)
146+
expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000')
147+
expect(attr('[data-stage="orb"]', 'opacity')).toBe('0.0000')
148+
expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/)
149+
expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000')
150+
})
151+
152+
it('eases the same filter to identity at both wordmark boundaries', () => {
153+
advanceTo(1300)
154+
expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000')
155+
expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/)
156+
157+
advanceTo(1301)
158+
expect(Number(attr('[data-goo]', 'stdDeviation'))).toBeLessThan(0.001)
159+
expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/)
160+
161+
advanceTo(1800)
162+
expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/)
163+
164+
advanceTo(2500)
165+
expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000')
166+
167+
advanceTo(15199)
168+
expect(Number(attr('[data-goo]', 'stdDeviation'))).toBeLessThan(0.001)
169+
expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/)
170+
171+
advanceTo(15200)
172+
expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000')
173+
expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/)
174+
expect(host?.querySelector('feComposite')).toBeNull()
175+
})
176+
122177
it('stops requesting frames on unmount', () => {
123178
advanceTo(500)
124179
act(() => root?.unmount())

apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,9 @@ const ORB_BEAT = 450
4343
/** Closing hold on the wordmark before the loop wraps back to the opening hold. */
4444
const HOLD_LOGO_END = 1700
4545
const TAIL = 200
46-
/** Goo blur while liquid (through the cycle) and while crisp (the wordmark). */
46+
/** Blur range for the filtered portion of the morph. */
4747
const GOO_HI = 5
4848
const GOO_LO = 0.55
49-
/** Post-threshold blur, about half a device pixel at the mark's largest size. */
50-
const EDGE_SMOOTHING = 0.16
5149
/**
5250
* Shapes that restart from compact when they appear and play exactly one pulse
5351
* of this many ms (just under a loop, so the dots reach the edge without
@@ -356,13 +354,18 @@ interface StageNode {
356354
key: StageKey
357355
}
358356

357+
interface GooFilterNodes {
358+
blur: SVGFEGaussianBlurElement
359+
matrix: SVGFEColorMatrixElement
360+
}
361+
359362
/**
360363
* Paints one frame of the choreography at `t` ms into the cycle by writing
361364
* SVG attributes directly - no React render per frame.
362365
*/
363366
function paintFrame(
364367
t: number,
365-
blur: SVGFEGaussianBlurElement,
368+
goo: GooFilterNodes,
366369
stages: StageNode[],
367370
anims: AnimatedNode[]
368371
): void {
@@ -411,8 +414,17 @@ function paintFrame(
411414
smooth(T_LOGO_HOLD_END, T_INTRO_END, t),
412415
1 - smooth(T_OUTRO_START, T_OUTRO_END, t)
413416
)
414-
const deviation = round(GOO_LO + (GOO_HI - GOO_LO) * liquid)
415-
if (blur.getAttribute('stdDeviation') !== deviation) blur.setAttribute('stdDeviation', deviation)
417+
/** Ease the filter to identity at rest without overlaying the unfiltered shapes. */
418+
const strength = Math.min(
419+
smooth(T_LOGO_HOLD_END, T_LOGO_HOLD_END + MORPH, t),
420+
1 - smooth(T_OUTRO_END - MORPH, T_OUTRO_END, t)
421+
)
422+
const deviation = round((GOO_LO + (GOO_HI - GOO_LO) * liquid) * strength)
423+
if (goo.blur.getAttribute('stdDeviation') !== deviation) {
424+
goo.blur.setAttribute('stdDeviation', deviation)
425+
}
426+
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)}`
427+
if (goo.matrix.getAttribute('values') !== matrix) goo.matrix.setAttribute('values', matrix)
416428
}
417429

418430
interface FooterWordmarkLoopProps {
@@ -454,7 +466,9 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
454466
const svg = svgRef.current
455467
if (!svg) return
456468
const blur = svg.querySelector<SVGFEGaussianBlurElement>('[data-goo]')
457-
if (!blur) return
469+
const matrix = svg.querySelector<SVGFEColorMatrixElement>('[data-goo-matrix]')
470+
if (!blur || !matrix) return
471+
const goo: GooFilterNodes = { blur, matrix }
458472

459473
const stages: StageNode[] = Array.from(
460474
svg.querySelectorAll<SVGGElement>('[data-stage]'),
@@ -475,7 +489,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
475489
const tick = (now: number) => {
476490
if (previous !== null) elapsed += Math.min(now - previous, MAX_FRAME_STEP)
477491
previous = now
478-
paintFrame(elapsed % CYCLE_MS, blur, stages, anims)
492+
paintFrame(elapsed % CYCLE_MS, goo, stages, anims)
479493
frame = requestAnimationFrame(tick)
480494
}
481495
const play = () => {
@@ -492,7 +506,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
492506
if (reducedMotion?.matches) {
493507
pause()
494508
elapsed = 0
495-
paintFrame(0, blur, stages, anims)
509+
paintFrame(0, goo, stages, anims)
496510
} else {
497511
play()
498512
}
@@ -536,20 +550,16 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
536550
height='160%'
537551
colorInterpolationFilters='sRGB'
538552
>
539-
<feGaussianBlur data-goo='' in='SourceGraphic' stdDeviation={GOO_LO} result='blur' />
553+
<feGaussianBlur data-goo='' in='SourceGraphic' stdDeviation={0} result='blur' />
540554
{/* A steep threshold: the melt between shapes keeps its liquid
541555
merges, but every edge resolves within a pixel, so the mark
542556
stays crisp at the cycle's full blur. */}
543557
<feColorMatrix
558+
data-goo-matrix=''
544559
in='blur'
545-
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 40 -19'
560+
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0'
546561
result='goo'
547562
/>
548-
{/* The threshold discards the rasterizer's edge coverage, so at the
549-
resting blur the wordmark's edge fell inside a device pixel and
550-
stair-stepped at the largest size. A sub-pixel blur after it
551-
restores ordinary anti-aliasing without touching the melt. */}
552-
<feGaussianBlur in='goo' stdDeviation={EDGE_SMOOTHING} />
553563
</filter>
554564
<radialGradient id={inkId} cx='0.5' cy='0.5' r='0.5'>
555565
<stop style={INK_STOP_INNER} />

apps/sim/app/_shell/providers/theme-provider.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const { mockUsePathname } = vi.hoisted(() => ({ mockUsePathname: vi.fn() }))
99

1010
vi.mock('next/navigation', () => ({ usePathname: mockUsePathname }))
1111

12+
import { syncThemeToNextThemes } from '@/lib/core/utils/theme'
1213
import { ThemeProvider } from '@/app/_shell/providers/theme-provider'
1314

1415
let root: Root
@@ -37,6 +38,15 @@ function render(pathname: string) {
3738

3839
beforeEach(() => {
3940
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
41+
/** The global storage mock is not a native jsdom Storage instance. */
42+
vi.stubGlobal(
43+
'StorageEvent',
44+
class extends window.StorageEvent {
45+
constructor(type: string, init: StorageEventInit) {
46+
super(type, { ...init, storageArea: null })
47+
}
48+
}
49+
)
4050
stubDarkOs()
4151
localStorage.clear()
4252
document.documentElement.className = ''
@@ -74,4 +84,61 @@ describe('ThemeProvider theme stores', () => {
7484
localStorage.setItem('sim-landing-theme', 'dark')
7585
expect(render('/login')).toContain('light')
7686
})
87+
88+
it.each(['/', '/blog', '/customers/example'])(
89+
'keeps %s light when account settings resolve dark',
90+
(pathname) => {
91+
localStorage.setItem('sim-theme', 'dark')
92+
const classes = render(pathname)
93+
expect(classes).toContain('light')
94+
95+
act(() => syncThemeToNextThemes('dark'))
96+
97+
expect(classes).toContain('light')
98+
expect(classes).not.toContain('dark')
99+
}
100+
)
101+
102+
it('preserves the landing footer choice when account settings change', () => {
103+
localStorage.setItem('sim-landing-theme', 'dark')
104+
const classes = render('/workflows')
105+
106+
act(() => syncThemeToNextThemes('light'))
107+
108+
expect(classes).toContain('dark')
109+
expect(localStorage.getItem('sim-landing-theme')).toBe('dark')
110+
expect(localStorage.getItem('sim-theme')).toBe('light')
111+
})
112+
113+
it('preserves the forced auth theme when account settings resolve', () => {
114+
const classes = render('/login')
115+
116+
act(() => syncThemeToNextThemes('dark'))
117+
118+
expect(classes).toContain('light')
119+
expect(classes).not.toContain('dark')
120+
})
121+
122+
it('updates the workspace theme when account settings resolve', () => {
123+
localStorage.setItem('sim-theme', 'light')
124+
const classes = render('/workspace/ws-1/home')
125+
expect(classes).toContain('light')
126+
127+
act(() => syncThemeToNextThemes('dark'))
128+
129+
expect(classes).toContain('dark')
130+
expect(classes).not.toContain('light')
131+
expect(document.documentElement.style.colorScheme).toBe('dark')
132+
})
133+
134+
it('resolves the workspace system theme through the active provider', () => {
135+
localStorage.setItem('sim-theme', 'light')
136+
const classes = render('/workspace/ws-1/home')
137+
138+
act(() => syncThemeToNextThemes('system'))
139+
140+
expect(classes).toContain('dark')
141+
expect(document.documentElement.style.colorScheme).toBe('dark')
142+
expect(localStorage.getItem('sim-theme')).toBe('system')
143+
})
77144
})

apps/sim/lib/core/utils/theme.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ describe('syncThemeToNextThemes', () => {
2525
expect(add).not.toHaveBeenCalled()
2626
})
2727

28-
it('repairs the document class without emitting a redundant storage event', () => {
28+
it('leaves document classes to the active theme provider', () => {
2929
localStorage.setItem('sim-theme', 'dark')
3030
document.documentElement.classList.add('light')
3131
const dispatchEvent = vi.spyOn(window, 'dispatchEvent')
3232

3333
syncThemeToNextThemes('dark')
3434

3535
expect(dispatchEvent).not.toHaveBeenCalled()
36-
expect(document.documentElement.classList.contains('dark')).toBe(true)
37-
expect(document.documentElement.classList.contains('light')).toBe(false)
36+
expect(document.documentElement.classList.contains('light')).toBe(true)
37+
expect(document.documentElement.classList.contains('dark')).toBe(false)
3838
})
3939
})

apps/sim/lib/core/utils/theme.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
/**
66
* Updates the theme in next-themes by dispatching a storage event.
77
* This works by updating localStorage and notifying next-themes of the change.
8+
* The active provider owns document classes, including forced themes and the
9+
* landing surface's independent preference.
810
* @param theme - The desired theme ('system', 'light', or 'dark')
911
*/
1012
export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') {
@@ -24,17 +26,4 @@ export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') {
2426
})
2527
)
2628
}
27-
28-
const root = document.documentElement
29-
const appliedTheme =
30-
theme === 'system'
31-
? window.matchMedia('(prefers-color-scheme: dark)').matches
32-
? 'dark'
33-
: 'light'
34-
: theme
35-
const oppositeTheme = appliedTheme === 'dark' ? 'light' : 'dark'
36-
if (root.classList.contains(appliedTheme) && !root.classList.contains(oppositeTheme)) return
37-
38-
root.classList.remove('light', 'dark')
39-
root.classList.add(appliedTheme)
4029
}

0 commit comments

Comments
 (0)