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
29 changes: 22 additions & 7 deletions apps/web/src/features/dashboard/DashboardHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import '../life/life.css'
import './header/dashboard-header.css'
import ActiveIndicator from '../../shared/motion/ActiveIndicator'
import DeskProps from './desk/DeskProps'
import CloudTrain from './header/CloudTrain'
import SpringField from './header/SpringField'
import HeaderSceneControls from './header/HeaderSceneControls'
import { useHeaderScene, useSceneReducedMotion } from './header/useHeaderScene'
import { useThemeMode } from '../../shared/theme/useThemeMode'

export type DashboardPage = 'main' | 'life' | 'news' | 'custom'

Expand Down Expand Up @@ -81,6 +86,13 @@ const DashboardHeader = ({
onSetPage,
}: DashboardHeaderProps) => {
const { language, t } = useI18n()
const [scene, updateScene] = useHeaderScene()
const reducedMotion = useSceneReducedMotion()
const theme = useThemeMode()
const trainPalette = scene.palette === 'auto' ? (theme === 'dark' ? 'night' : 'sunset') : scene.palette
const springPalette = scene.spring.palette === 'auto' ? (theme === 'dark' ? 'night' : 'day') : scene.spring.palette
const scenery = scene.mode !== 'desk'
const activeSettings = scene.mode === 'spring' ? scene.spring : scene
const headerRef = useRef<HTMLElement | null>(null)
const today = useToday()
const dayKey = dayKeyOf(today)
Expand Down Expand Up @@ -142,8 +154,10 @@ const DashboardHeader = ({
// its epigraph), then a ruled toolbar to operate: the views as tabs on the
// left, the layout actions on the right (DESIGN.md › Dashboard header).
return (
<header ref={headerRef} className="app-shell__header dash-header">
<div className="dash-hero">
<header ref={headerRef} className={`app-shell__header dash-header${scenery ? ' dash-header--scenery' : ''}`} data-header-scene={scene.mode} style={scenery ? { minHeight: activeSettings.sceneHeight } : undefined}>
{scene.mode === 'cloudtrain' && <CloudTrain settings={scene} anchor={scene.sceneAnchor} palette={trainPalette} paused={scene.paused || reducedMotion} />}
{scene.mode === 'spring' && <SpringField settings={scene.spring} palette={springPalette} paused={scene.spring.paused || reducedMotion} />}
<div className="dash-hero" style={scenery && activeSettings.height > 80 ? { minHeight: activeSettings.height } : undefined}>
<div className="dash-hero__day">
<time className="dash-hero__date" dateTime={dayKey}>
{zh ? (
Expand Down Expand Up @@ -282,8 +296,9 @@ const DashboardHeader = ({
</div>
) : <span />}

{showLayoutActions ? (
<div className="dash-toolbar__actions">
<div className="dash-toolbar__actions">
<HeaderSceneControls scene={scene} update={updateScene} reduced={reducedMotion} />
{showLayoutActions ? <>
{layoutEdit ? (
<button
type="button"
Expand All @@ -309,10 +324,10 @@ const DashboardHeader = ({
<span>{layoutEdit ? t('dashboard.done') : t('dashboard.editLayout')}</span>
{!layoutEdit && layoutEditLocked ? <PremiumMark /> : null}
</button>
</div>
) : null}
</> : null}
</div>
</div>
<DeskProps headerRef={headerRef} />
{scene.mode === 'desk' && <DeskProps headerRef={headerRef} />}
</header>
)
}
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/features/dashboard/header/CloudTrain.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { StrictMode } from 'react'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CloudTrain from './CloudTrain'
import { TRAIN_DEFAULTS } from './sceneSettings'
import { useHeaderScene, useSceneReducedMotion, HEADER_SCENE_KEY } from './useHeaderScene'
import { createTrainRenderer } from './cloudtrain/renderer'

// Auto-mocking also supports StrictMode's two concurrent dynamic imports;
// Vitest 3's manual factory recursion guard bypasses the mock for the second.
vi.mock('./cloudtrain/renderer')
const renderer = { create: vi.mocked(createTrainRenderer), update: vi.fn(), dispose: vi.fn() }
let reduced = false
const mediaListeners = new Set<() => void>()
let connection: EventTarget & { saveData: boolean }
beforeEach(() => {
localStorage.clear(); reduced = false; mediaListeners.clear()
delete document.documentElement.dataset.motion
connection = Object.assign(new EventTarget(), { saveData: false })
Object.defineProperty(navigator, 'connection', { configurable: true, value: connection })
vi.stubGlobal('matchMedia', () => ({ get matches() { return reduced }, addEventListener: (_: string, fn: () => void) => mediaListeners.add(fn), removeEventListener: (_: string, fn: () => void) => mediaListeners.delete(fn) }))
renderer.create.mockReset(); renderer.update.mockReset(); renderer.dispose.mockReset()
renderer.create.mockImplementation((_canvas, _options, onReady) => { onReady(true); return { update: renderer.update, dispose: renderer.dispose } })
})
afterEach(async () => { cleanup(); await vi.dynamicImportSettled(); vi.restoreAllMocks(); vi.unstubAllGlobals(); delete document.documentElement.dataset.motion })

function Harness() {
const [scene, update] = useHeaderScene()
const reducedMotion = useSceneReducedMotion()
return <><button onClick={() => update({ paused: !scene.paused })}>pause</button><CloudTrain palette={scene.palette === 'auto' ? 'sunset' : scene.palette} paused={scene.paused || reducedMotion} /></>
}
describe('cloud train component lifecycle', () => {
it('does not create a renderer after an asynchronous mount was cancelled', async () => {
const { unmount } = render(<CloudTrain palette="sunset" paused={false} />)
unmount()
await act(async () => { await vi.dynamicImportSettled() })
expect(renderer.create).not.toHaveBeenCalled()
})
it('creates only one live renderer in StrictMode and disposes it when leaving', async () => {
const { rerender, unmount } = render(<StrictMode><CloudTrain palette="sunset" paused={false} /></StrictMode>)
await waitFor(() => expect(renderer.create).toHaveBeenCalledTimes(1))
rerender(<StrictMode><CloudTrain palette="night" paused settings={{ ...TRAIN_DEFAULTS, speed: 1.2, steam: 0, trainScale: 140 }} /></StrictMode>)
expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, speed: 1.2, steam: 0, trainScale: 140, palette: 'night', paused: true })
expect(renderer.create).toHaveBeenCalledTimes(1)
unmount(); expect(renderer.dispose).toHaveBeenCalledTimes(1)
})
it('keeps the still illustration when WebGL cannot render', async () => {
renderer.create.mockImplementation((_canvas, _options, onReady) => { onReady(false); return { update: renderer.update, dispose: renderer.dispose } })
const { container } = render(<CloudTrain palette="morning" paused={false} />)
await waitFor(() => expect(renderer.create).toHaveBeenCalledTimes(1))
expect(container.querySelector('.dash-cloudtrain')?.getAttribute('data-ready')).toBe('false')
expect(container.querySelector('svg')).not.toBeNull()
})
it('respects OS, app, save-data and persisted manual pause independently', async () => {
const { unmount } = render(<Harness />)
await waitFor(() => expect(renderer.create).toHaveBeenCalledTimes(1))
act(() => { reduced = true; mediaListeners.forEach(fn => fn()) })
expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, palette: 'sunset', paused: true })
await act(async () => { document.documentElement.dataset.motion = 'reduce'; reduced = false; mediaListeners.forEach(fn => fn()) })
expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, palette: 'sunset', paused: true })
act(() => { connection.saveData = true; connection.dispatchEvent(new Event('change')) })
await act(async () => { delete document.documentElement.dataset.motion })
expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, palette: 'sunset', paused: true })
act(() => { connection.saveData = false; connection.dispatchEvent(new Event('change')) })
expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, palette: 'sunset', paused: false })
act(() => { localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ paused: true, palette: 'night' })); window.dispatchEvent(new StorageEvent('storage', { key: HEADER_SCENE_KEY })) })
expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, palette: 'night', paused: true })
act(() => { reduced = true; mediaListeners.forEach(fn => fn()); reduced = false; mediaListeners.forEach(fn => fn()) })
expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, palette: 'night', paused: true })
unmount(); expect(mediaListeners.size).toBe(0)
})
})
40 changes: 40 additions & 0 deletions apps/web/src/features/dashboard/header/CloudTrain.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react'
import type { TrainPalette, TrainRenderer } from './cloudtrain/renderer'
import { TRAIN_DEFAULTS, type TrainSettings } from './sceneSettings'

export default function CloudTrain({ palette, paused, settings = TRAIN_DEFAULTS, anchor = 'bottom' }: { palette: TrainPalette; paused: boolean; settings?: TrainSettings; anchor?: 'top' | 'bottom' }) {
const canvas = useRef<HTMLCanvasElement>(null)
const renderer = useRef<TrainRenderer | null>(null)
const options = useRef({ ...settings, palette, paused })
const [ready, setReady] = useState(false)
useEffect(() => {
options.current = { ...settings, palette, paused }
renderer.current?.update(options.current)
}, [palette, paused, settings])
useEffect(() => {
let cancelled = false
void import('./cloudtrain/renderer').then(({ createTrainRenderer }) => {
if (cancelled || !canvas.current) return
renderer.current = createTrainRenderer(canvas.current, options.current, setReady)
}).catch(() => { if (!cancelled) setReady(false) })
return () => { cancelled = true; renderer.current?.dispose(); renderer.current = null }
}, [])
return (
<div className="dash-cloudtrain-frame" aria-hidden="true">
<div className="dash-cloudtrain" data-palette={palette} data-ready={ready} data-paused={paused} data-anchor={anchor} aria-hidden="true" style={{
'--train-opacity': settings.opacity / 100, '--train-width': settings.width / 100,
'--train-scale': settings.trainScale / 100, '--train-offset': `${settings.railOffset}%`,
'--scene-x': `${settings.sceneX}%`, '--scene-y': `${settings.sceneY}%`,
'--scene-height': `${settings.sceneHeight}px`,
} as CSSProperties}>
<svg className="dash-cloudtrain__still" viewBox="400 135 340 110" preserveAspectRatio="xMidYMid meet">
<path className="dash-cloudtrain__cloud-back" d="M0 140 Q70 60 140 130 Q210 80 290 150 Q380 40 470 120 Q550 65 630 140 Q730 40 820 115 Q920 75 1000 140 Q1100 50 1200 110V300H0Z" />
<path className="dash-cloudtrain__cloud-front" d="M0 205 Q90 125 170 200 Q270 150 350 220 Q440 140 520 195 Q630 135 720 205 Q820 160 900 210 Q1030 120 1200 200V300H0Z" />
<g fill="none" stroke="currentColor" strokeWidth="2"><path d="M0 185Q150 270 300 185Q450 270 600 185Q750 270 900 185Q1050 270 1200 185" /><path strokeWidth="5" d="M0 228H1200M300 180V300M600 180V300M900 180V300" /></g>
<g fill="currentColor"><path d="M425 201h65v21h-65zm72 0h65v21h-65zm72 8h38v13h-38zm45-17h24v30h-24zm24 10h52v20h-52zm39-20h8v22h-8z" /><circle cx="438" cy="224" r="4"/><circle cx="477" cy="224" r="4"/><circle cx="510" cy="224" r="4"/><circle cx="550" cy="224" r="4"/><circle cx="580" cy="224" r="4"/><circle cx="600" cy="224" r="4"/><circle cx="646" cy="222" r="6"/><circle cx="664" cy="222" r="6"/><circle cx="682" cy="222" r="6"/></g>
</svg>
<canvas ref={canvas} className="dash-cloudtrain__canvas" />
</div>
</div>
)
}
Loading
Loading