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
5 changes: 3 additions & 2 deletions apps/web/src/features/dashboard/DashboardHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,14 @@ const DashboardHeader = ({
}

const showLayoutActions = page !== 'news' && page !== 'custom'
const panorama = scenery && activeSettings.framing === 'panorama'

// A masthead to read (the date on the left, the day's line on the right as
// 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${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} />}
<header ref={headerRef} className={`app-shell__header dash-header${scenery ? ' dash-header--scenery' : ''}`} data-header-scene={scene.mode} data-scene-framing={scenery ? activeSettings.framing : undefined} style={scenery ? { minHeight: activeSettings.sceneHeight } : undefined}>
{scene.mode === 'cloudtrain' && <CloudTrain panorama={panorama} 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 dash-hero--notes" style={scenery && activeSettings.height > 80 ? { minHeight: activeSettings.height } : undefined}>
<div className="dash-hero__day">
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/features/dashboard/hanging-notes/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1074,10 +1074,10 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on
const header = dashboard.querySelector('.dash-header');
if (header)
observer.observe(header);
// Quote swaps replace the figure; header size alone can remain unchanged.
// Quotes and scenery framing can move the hanger without resizing the header.
const headerObserver = new MutationObserver(() => measure());
if (header)
headerObserver.observe(header, { childList: true, subtree: true, characterData: true });
headerObserver.observe(header, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['data-scene-framing'] });
listen(window, 'resize', measure);
void document.fonts?.ready.then(() => { if (!disposed)
measure(); });
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/features/dashboard/header/CloudTrain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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' }) {
export default function CloudTrain({ palette, paused, settings = TRAIN_DEFAULTS, anchor = 'bottom', panorama = false }: { palette: TrainPalette; paused: boolean; settings?: TrainSettings; anchor?: 'top' | 'bottom'; panorama?: boolean }) {
const canvas = useRef<HTMLCanvasElement>(null)
const renderer = useRef<TrainRenderer | null>(null)
const options = useRef({ ...settings, palette, paused })
Expand All @@ -27,7 +27,7 @@ export default function CloudTrain({ palette, paused, settings = TRAIN_DEFAULTS,
'--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">
<svg className="dash-cloudtrain__still" viewBox={panorama ? "0 80 1200 180" : "400 135 340 110"} preserveAspectRatio={panorama ? "xMidYMid slice" : "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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,34 @@ const onSetPage = vi.fn(), onToggleLayoutEdit = vi.fn()
const renderHeader = () => render(<ToastContext.Provider value={{ push: vi.fn() }}><DashboardHeader layoutEdit={false} widgetsPanelOpen={false} onToggleLayoutEdit={onToggleLayoutEdit} onToggleWidgetsPanel={vi.fn()} onSetPage={onSetPage} /></ToastContext.Provider>)

describe('dashboard header scene integration', () => {
it('fills legacy scenes by default and restores saved placement when opting into free placement', () => {
localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ height: 180, width: 82, sceneHeight: 110, sceneX: 5, spring: { height: 160, width: 65, sceneHeight: 150 } }))
const first = renderHeader()
expect(first.container.querySelector('.dash-header')).toHaveAttribute('data-scene-framing', 'panorama')
expect(first.container.querySelector('.dash-header')).toHaveStyle({ minHeight: '110px' })
expect(first.container.querySelector('.dash-hero')).toHaveStyle({ minHeight: '180px' })
fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' }))
expect(screen.queryByRole('slider', { name: '动画宽度' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /自由摆放/ }))
expect(screen.getByRole('slider', { name: '动画宽度' })).toHaveValue('82')
expect(screen.getByRole('slider', { name: '动画高度' })).toHaveValue('110')
expect(first.container.querySelector('.dash-header')).toHaveStyle({ minHeight: '110px' })
fireEvent.click(screen.getByRole('button', { name: '春日田野' }))
expect(first.container.querySelector('.dash-header')).toHaveAttribute('data-scene-framing', 'panorama')
fireEvent.click(screen.getByRole('button', { name: /自由摆放/ }))
expect(screen.getByRole('slider', { name: '动画宽度' })).toHaveValue('65')
expect(first.container.querySelector('.dash-header')).toHaveStyle({ minHeight: '150px' })
expect(first.container.querySelector('.dash-hero')).toHaveStyle({ minHeight: '160px' })
fireEvent.click(screen.getByRole('button', { name: /全景铺满/ }))
first.unmount()
const second = renderHeader()
expect(second.container.querySelector('.dash-header')).toHaveAttribute('data-scene-framing', 'panorama')
expect(second.container.querySelector('.dash-header')).toHaveStyle({ minHeight: '150px' })
expect(second.container.querySelector('.dash-hero')).toHaveStyle({ minHeight: '160px' })
expect(readHeaderScene()).toMatchObject({ framing: 'custom', width: 82, sceneHeight: 110, spring: { framing: 'panorama', width: 65, sceneHeight: 150 } })
})
it('keeps numeric dimensions consistent when repeated out-of-range entries normalize to the same value', () => {
localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ ...DEFAULT_HEADER_SCENE, mode: 'spring', spring: { ...SPRING_DEFAULTS, width: 150, sceneHeight: 32 } }))
localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ ...DEFAULT_HEADER_SCENE, mode: 'spring', spring: { ...SPRING_DEFAULTS, framing: 'custom', width: 150, sceneHeight: 32 } }))
renderHeader()
fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' }))
const width = screen.getByRole('spinbutton', { name: '输入动画宽度' })
Expand All @@ -36,13 +62,14 @@ describe('dashboard header scene integration', () => {
expect(readHeaderScene().spring).toMatchObject({ width: 150, sceneHeight: 32 })
})
it('preserves independent train and spring settings across switching, remounting and current-scene resets', () => {
localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ ...DEFAULT_HEADER_SCENE, width: 70, sceneHeight: 160, sceneX: -10, steam: 125, palette: 'night', paused: true }))
localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ ...DEFAULT_HEADER_SCENE, framing: 'custom', width: 70, sceneHeight: 160, sceneX: -10, steam: 125, palette: 'night', paused: true }))
const first = renderHeader()
fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' }))
fireEvent.click(screen.getByRole('button', { name: '春日田野' }))
expect(screen.getByTestId('spring')).toBeInTheDocument()
expect(screen.queryByTestId('train')).toBeNull()
expect(screen.queryByTestId('desk')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /自由摆放/ }))
fireEvent.change(screen.getByRole('spinbutton', { name: '输入动画宽度' }), { target: { value: '65' } })
fireEvent.blur(screen.getByRole('spinbutton', { name: '输入动画宽度' }))
fireEvent.change(screen.getByRole('slider', { name: '动画高度' }), { target: { value: '220' } })
Expand Down Expand Up @@ -124,6 +151,7 @@ describe('dashboard header scene integration', () => {
it('previews custom dimensions, preserves them on reload and restores the original layout', () => {
const first = renderHeader()
fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' }))
fireEvent.click(screen.getByRole('button', { name: /自由摆放/ }))
fireEvent.change(screen.getByLabelText('顶栏高度', { selector: 'input' }), { target: { value: '160' } })
fireEvent.change(screen.getByLabelText('动画宽度', { selector: 'input[type="range"]' }), { target: { value: '70' } })
fireEvent.change(screen.getByLabelText('动画高度', { selector: 'input[type="range"]' }), { target: { value: '200' } })
Expand Down
19 changes: 15 additions & 4 deletions apps/web/src/features/dashboard/header/HeaderSceneControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default function HeaderSceneControls({ scene, update, reduced }: {
const id = useId()
const spring = scene.mode === 'spring'
const active = spring ? scene.spring : scene
const custom = active.framing === 'custom'
// All numeric fields have explicit ranges; only the active scene's controls render.
const values = spring ? { ...scene, ...scene.spring } : { ...scene.spring, ...scene }
const ranges = spring ? { ...TRAIN_RANGES, ...SPRING_RANGES } : { ...SPRING_RANGES, ...TRAIN_RANGES }
Expand Down Expand Up @@ -76,8 +77,17 @@ export default function HeaderSceneControls({ scene, update, reduced }: {
{([['auto', zh ? '跟随主题' : 'Match theme'], ['day', zh ? '春日' : 'Daylight'], ['dusk', zh ? '晚霞' : 'Dusk'], ['night', zh ? '月色' : 'Moonlight']] as const).map(([value, label]) => <button key={value} type="button" aria-pressed={scene.spring.palette === value} onClick={() => update({ spring: { palette: value } })}><span className="dash-scene-swatch" data-palette={`spring-${value}`} aria-hidden="true" />{label}</button>)}
</div>
</>}
<div className="dash-scene-framing" role="group" aria-label={zh ? '画面构图' : 'Scenery framing'}>
<button type="button" aria-pressed={!custom} onClick={() => updateActive({ framing: 'panorama' })}>
<span>{zh ? '全景铺满' : 'Panorama'}</span><small>{zh ? '铺满原有顶栏' : 'Fill the existing header'}</small>
</button>
<button type="button" aria-pressed={custom} onClick={() => updateActive({ framing: 'custom' })}>
<span>{zh ? '自由摆放' : 'Free placement'}</span><small>{zh ? '自己调整尺寸与位置' : 'Your size & position'}</small>
</button>
</div>
<details className="dash-scene-section" open>
<summary>{zh ? '尺寸与位置' : 'Size & placement'}</summary>
<summary>{zh ? (custom ? '尺寸与位置' : '画面细节') : (custom ? 'Size & placement' : 'Scene details')}</summary>
{custom && <>
<div className="dash-scene-anchor" role="group" aria-label={zh ? '动画对齐方式' : 'Scenery alignment'}>
<button type="button" aria-pressed={active.sceneAnchor === 'bottom' && active.sceneY === 0} onClick={() => updateActive({ sceneAnchor: 'bottom', sceneY: 0 })}>{zh ? '贴紧顶栏底部' : 'Pin to header bottom'}</button>
<button type="button" aria-pressed={active.sceneAnchor === 'top' && active.sceneY === 0} onClick={() => updateActive({ sceneAnchor: 'top', sceneY: 0 })}>{zh ? '靠顶部' : 'Align to top'}</button>
Expand All @@ -88,10 +98,11 @@ export default function HeaderSceneControls({ scene, update, reduced }: {
{slider('sceneY', zh ? '动画上下位置' : 'Scenery vertical position')}
<div className="dash-scene-position-reset"><button type="button" onClick={() => updateActive({ sceneX: 0, sceneY: 0, sceneAnchor: 'bottom' })}>{zh ? '动画位置归位' : 'Reset scenery position'}</button></div>
<p className="dash-scene-hint">{zh ? '贴底时调整高度会向上展开。宽高可直接输入,放大时顶栏自动留出空间。' : 'Pinned scenery grows upward. Enter a width or height; the header makes room as needed.'}</p>
</>}
{spring ? slider('zoom', zh ? '景物大小' : 'Landscape scale') : <>{slider('trainScale', zh ? '列车大小' : 'Train size')}{slider('railOffset', zh ? '列车上下位置' : 'Train vertical position')}</>}
{slider('opacity', zh ? '风景浓度' : 'Scenery opacity')}
{slider('height', zh ? '顶栏高度' : 'Header height')}
<p className="dash-scene-hint">{zh ? '默认沿用原顶栏尺寸,窄屏会自动留出文字空间。' : 'Original header size by default. Small screens keep room for text.'}</p>
{custom && slider('height', zh ? '顶栏高度' : 'Header height')}
<p className="dash-scene-hint">{zh ? (custom ? '尺寸与位置会独立保留,随时可以切回全景。' : '保持顶栏原有高度,风景铺满背景,文字下方轻柔留白。') : (custom ? 'Your placement is kept when switching back to panorama.' : 'Scenery fills the existing header without changing its height, with soft space beneath the text.')}</p>
</details>
<details className="dash-scene-section">
<summary>{zh ? (spring ? '田野与动画' : '云海与动画') : (spring ? 'Fields & motion' : 'Clouds & motion')}</summary>
Expand All @@ -105,7 +116,7 @@ export default function HeaderSceneControls({ scene, update, reduced }: {
</details>
<div className="dash-scene-footer">
<span>{zh ? '各场景分别保存在此设备' : 'Saved separately for each scene'}</span>
<button type="button" onClick={() => update(spring ? { spring: { ...SPRING_DEFAULTS } } : { ...TRAIN_DEFAULTS, palette: 'auto', sceneAnchor: 'bottom', paused: false })}><RotateCcw size={12} aria-hidden="true" />{zh ? '恢复默认' : 'Reset defaults'}</button>
<button type="button" onClick={() => update(spring ? { spring: { ...SPRING_DEFAULTS } } : { ...TRAIN_DEFAULTS, palette: 'auto', sceneAnchor: 'bottom', paused: false, framing: 'panorama' })}><RotateCcw size={12} aria-hidden="true" />{zh ? '恢复默认' : 'Reset defaults'}</button>
</div>
</>}
</PopoverContent>
Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/features/dashboard/header/dashboard-header.css
Original file line number Diff line number Diff line change
Expand Up @@ -738,3 +738,87 @@ html[data-motion='reduce'] .dash-hero__quote-author {
.dash-scene-swatch[data-palette='spring-night'] { background: linear-gradient(135deg, #455f68, #8a7b8b); }
.dash-scene-toggle { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; color: var(--text-secondary); font-size: var(--fs-label); cursor: pointer; }
.dash-scene-toggle input { accent-color: var(--accent); width: 16px; height: 16px; }

/* Panorama is a background-only treatment. Keep the original hero, toolbar,
spacing and responsive layout so changing framing never adds header height.
Only scenery is clipped; notes keep the Dashboard-sized drag surface. */
.dash-header[data-scene-framing='panorama'] .dash-cloudtrain-frame {
inset-inline: 18px var(--dash-header-inset-end, 24px);
}
.dash-header[data-scene-framing='panorama'] :is(.dash-cloudtrain, .dash-springfield) {
inset: 0;
width: 100%;
height: 100%;
transform: none;
opacity: var(--train-opacity, .84);
mask-image: linear-gradient(90deg, transparent, #000 1.5%, #000 98.5%, transparent), linear-gradient(180deg, transparent, #000 4%, #000 96%, transparent);
}
.dash-header[data-scene-framing='panorama'] .dash-cloudtrain__still {
inset: 0;
width: 100%;
height: 100%;
max-height: none;
transform: none;
}
.dash-header[data-scene-framing='panorama'] :is(.dash-hero__day, .dash-hero__quote, .dash-toolbar) {
position: relative;
isolation: isolate;
z-index: 0;
}
.dash-header[data-scene-framing='panorama'] :is(.dash-hero__day, .dash-hero__quote)::before {
content: '';
position: absolute;
z-index: -1;
inset: -16px;
pointer-events: none;
background: radial-gradient(ellipse at 12% 50%, color-mix(in srgb, var(--bg) 94%, transparent) 12%, color-mix(in srgb, var(--bg) 68%, transparent) 48%, transparent 78%);
}
.dash-header[data-scene-framing='panorama'] .dash-hero__quote::before {
background: radial-gradient(ellipse at 88% 50%, color-mix(in srgb, var(--bg) 94%, transparent) 12%, color-mix(in srgb, var(--bg) 68%, transparent) 48%, transparent 78%);
}
.dash-header[data-scene-framing='panorama'] .dash-hero__quote-text {
color: var(--text-primary);
}
.dash-header[data-scene-framing='panorama'] .dash-hero__quote-by {
color: var(--text-secondary);
}
.dash-header[data-scene-framing='panorama'] .dash-toolbar::before {
content: '';
position: absolute;
z-index: -1;
inset: -8px 0 0;
pointer-events: none;
background: linear-gradient(transparent, color-mix(in srgb, var(--bg) 78%, transparent) 35%, var(--bg));
}
.dash-scene-framing {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 16px;
}
.dash-scene-framing button {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 5px;
border: 1px solid var(--rule);
border-radius: 8px;
padding: 10px;
background: var(--paper);
color: var(--text-secondary);
font: inherit;
font-size: var(--fs-label);
text-align: left;
cursor: pointer;
}
.dash-scene-framing button[aria-pressed='true'] {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 55%, var(--rule));
background: var(--accent-soft);
}
.dash-scene-framing small { font-size: 10px; color: var(--text-secondary); line-height: 1.4; }
@container (max-width: 780px) {
.dash-header[data-scene-framing='panorama'] .dash-hero__quote::before {
background: radial-gradient(ellipse, color-mix(in srgb, var(--bg) 88%, transparent) 10%, color-mix(in srgb, var(--bg) 58%, transparent) 46%, transparent 72%);
}
}
Loading
Loading