Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)) {
Expand Down Expand Up @@ -79,7 +89,10 @@ 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('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('<feGaussianBlur in="goo"')
for (const shape of SHAPES) {
expect(html).toContain(`data-stage="${shape}" opacity="0"`)
}
Expand All @@ -90,12 +103,14 @@ describe('FooterWordmarkLoop', () => {

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]', '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-matrix]', 'values')).toMatch(/40\.000 -19\.000$/)

advanceTo(3900)
expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('1.0000')
Expand All @@ -112,13 +127,53 @@ 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]', '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 identity filter when reduced motion is enabled mid-morph', () => {
advanceTo(2700)
expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/)

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-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', () => {
advanceTo(500)
act(() => root?.unmount())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -356,13 +354,18 @@ interface StageNode {
key: StageKey
}

interface GooFilterNodes {
blur: SVGFEGaussianBlurElement
matrix: SVGFEColorMatrixElement
}

/**
* 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 {
Expand Down Expand Up @@ -411,8 +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)
if (blur.getAttribute('stdDeviation') !== deviation) blur.setAttribute('stdDeviation', deviation)
/** 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)
}
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 {
Expand Down Expand Up @@ -454,7 +466,9 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
const svg = svgRef.current
if (!svg) return
const blur = svg.querySelector<SVGFEGaussianBlurElement>('[data-goo]')
if (!blur) return
const matrix = svg.querySelector<SVGFEColorMatrixElement>('[data-goo-matrix]')
if (!blur || !matrix) return
const goo: GooFilterNodes = { blur, matrix }

const stages: StageNode[] = Array.from(
svg.querySelectorAll<SVGGElement>('[data-stage]'),
Expand All @@ -475,7 +489,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 = () => {
Expand All @@ -492,7 +506,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
if (reducedMotion?.matches) {
pause()
elapsed = 0
paintFrame(0, blur, stages, anims)
paintFrame(0, goo, stages, anims)
} else {
play()
}
Expand Down Expand Up @@ -536,20 +550,16 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
height='160%'
colorInterpolationFilters='sRGB'
>
<feGaussianBlur data-goo='' in='SourceGraphic' stdDeviation={GOO_LO} result='blur' />
<feGaussianBlur data-goo='' in='SourceGraphic' stdDeviation={0} result='blur' />
{/* 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. */}
<feColorMatrix
data-goo-matrix=''
in='blur'
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 40 -19'
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0'
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. */}
<feGaussianBlur in='goo' stdDeviation={EDGE_SMOOTHING} />
</filter>
<radialGradient id={inkId} cx='0.5' cy='0.5' r='0.5'>
<stop style={INK_STOP_INNER} />
Expand Down
67 changes: 67 additions & 0 deletions apps/sim/app/_shell/providers/theme-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const { mockUsePathname } = vi.hoisted(() => ({ 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
Expand Down Expand Up @@ -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 = ''
Expand Down Expand Up @@ -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')
})
})
6 changes: 3 additions & 3 deletions apps/sim/lib/core/utils/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ 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')

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)
})
})
15 changes: 2 additions & 13 deletions apps/sim/lib/core/utils/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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)
}
Loading