Skip to content

Commit d2fb353

Browse files
committed
fix(landing): prevent theme flashes and sharpen footer animation
1 parent f5e28a1 commit d2fb353

5 files changed

Lines changed: 125 additions & 34 deletions

File tree

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

Lines changed: 31 additions & 3 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)) {
@@ -80,6 +90,8 @@ describe('FooterWordmarkLoop', () => {
8090
expect(html).toContain('data-stage="wm" opacity="1"')
8191
expect(html).toContain('data-stage="orb" opacity="0"')
8292
expect(html).toContain('stdDeviation="0.55"')
93+
expect(html).toContain('data-goo-group="" filter="none"')
94+
expect(html).not.toContain('<feGaussianBlur in="goo"')
8395
for (const shape of SHAPES) {
8496
expect(html).toContain(`data-stage="${shape}" opacity="0"`)
8597
}
@@ -91,11 +103,13 @@ describe('FooterWordmarkLoop', () => {
91103
it('plays the master timeline: wordmark, orb, the seven shapes, orb, wordmark', () => {
92104
expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000')
93105
expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550')
106+
expect(attr('[data-goo-group]', 'filter')).toBe('none')
94107

95108
advanceTo(2700)
96109
expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000')
97110
expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000')
98111
expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000')
112+
expect(attr('[data-goo-group]', 'filter')).toMatch(/^url\(#fwl-goo-/)
99113

100114
advanceTo(3900)
101115
expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('1.0000')
@@ -113,12 +127,26 @@ describe('FooterWordmarkLoop', () => {
113127
expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000')
114128
expect(attr('[data-stage="thinking"]', 'opacity')).toBe('0.0000')
115129
expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550')
130+
expect(attr('[data-goo-group]', 'filter')).toBe('none')
116131

117132
advanceTo(CYCLE_MS + 2700)
118133
expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000')
119134
expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000')
120135
})
121136

137+
it('returns to an unfiltered wordmark when reduced motion is enabled mid-morph', () => {
138+
advanceTo(2700)
139+
expect(attr('[data-goo-group]', 'filter')).toMatch(/^url\(#fwl-goo-/)
140+
141+
reducedMotion = true
142+
act(() => onMotionPreference?.())
143+
144+
expect(pending).toHaveLength(0)
145+
expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000')
146+
expect(attr('[data-stage="orb"]', 'opacity')).toBe('0.0000')
147+
expect(attr('[data-goo-group]', 'filter')).toBe('none')
148+
})
149+
122150
it('stops requesting frames on unmount', () => {
123151
advanceTo(500)
124152
act(() => root?.unmount())

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

Lines changed: 22 additions & 15 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,19 @@ interface StageNode {
356354
key: StageKey
357355
}
358356

357+
interface GooFilterNodes {
358+
blur: SVGFEGaussianBlurElement
359+
group: SVGGElement
360+
url: string
361+
}
362+
359363
/**
360364
* Paints one frame of the choreography at `t` ms into the cycle by writing
361365
* SVG attributes directly - no React render per frame.
362366
*/
363367
function paintFrame(
364368
t: number,
365-
blur: SVGFEGaussianBlurElement,
369+
goo: GooFilterNodes,
366370
stages: StageNode[],
367371
anims: AnimatedNode[]
368372
): void {
@@ -412,7 +416,12 @@ function paintFrame(
412416
1 - smooth(T_OUTRO_START, T_OUTRO_END, t)
413417
)
414418
const deviation = round(GOO_LO + (GOO_HI - GOO_LO) * liquid)
415-
if (blur.getAttribute('stdDeviation') !== deviation) blur.setAttribute('stdDeviation', deviation)
419+
if (goo.blur.getAttribute('stdDeviation') !== deviation) {
420+
goo.blur.setAttribute('stdDeviation', deviation)
421+
}
422+
/** Resting vectors retain native antialiasing; only the liquid morph needs raster filtering. */
423+
const filter = liquid > 0 ? goo.url : 'none'
424+
if (goo.group.getAttribute('filter') !== filter) goo.group.setAttribute('filter', filter)
416425
}
417426

418427
interface FooterWordmarkLoopProps {
@@ -454,7 +463,9 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
454463
const svg = svgRef.current
455464
if (!svg) return
456465
const blur = svg.querySelector<SVGFEGaussianBlurElement>('[data-goo]')
457-
if (!blur) return
466+
const group = svg.querySelector<SVGGElement>('[data-goo-group]')
467+
if (!blur || !group) return
468+
const goo: GooFilterNodes = { blur, group, url: `url(#${gooId})` }
458469

459470
const stages: StageNode[] = Array.from(
460471
svg.querySelectorAll<SVGGElement>('[data-stage]'),
@@ -475,7 +486,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
475486
const tick = (now: number) => {
476487
if (previous !== null) elapsed += Math.min(now - previous, MAX_FRAME_STEP)
477488
previous = now
478-
paintFrame(elapsed % CYCLE_MS, blur, stages, anims)
489+
paintFrame(elapsed % CYCLE_MS, goo, stages, anims)
479490
frame = requestAnimationFrame(tick)
480491
}
481492
const play = () => {
@@ -492,7 +503,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
492503
if (reducedMotion?.matches) {
493504
pause()
494505
elapsed = 0
495-
paintFrame(0, blur, stages, anims)
506+
paintFrame(0, goo, stages, anims)
496507
} else {
497508
play()
498509
}
@@ -517,7 +528,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
517528
observer?.disconnect()
518529
reducedMotion?.removeEventListener('change', onMotionPreference)
519530
}
520-
}, [])
531+
}, [gooId])
521532

522533
return (
523534
<div className={cn('relative mx-auto aspect-[5/3] w-[clamp(180px,17vw,320px)]', className)}>
@@ -545,11 +556,6 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
545556
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 40 -19'
546557
result='goo'
547558
/>
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} />
553559
</filter>
554560
<radialGradient id={inkId} cx='0.5' cy='0.5' r='0.5'>
555561
<stop style={INK_STOP_INNER} />
@@ -575,7 +581,8 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
575581
</defs>
576582

577583
<g
578-
filter={`url(#${gooId})`}
584+
data-goo-group=''
585+
filter='none'
579586
fill={`url(#${inkId})`}
580587
stroke={`url(#${inkId})`}
581588
strokeWidth={0}

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)