Skip to content

Commit e3a1614

Browse files
authored
feat: header toggle for falling autumn leaves / 页头新增秋叶飘落开关 (#1250)
* feat: header toggle for the falling autumn leaves Adds a leaf button next to the theme toggle (and in the mobile menu) that hides or shows the October falling-leaves decoration. The opt-out persists in localStorage and a prepaint script applies it before first paint, so leaves never flash for people who turned them off. 中文:在主题切换按钮旁(以及移动端菜单中)添加树叶按钮,用于隐藏或显示十月的飘落树叶装饰。 关闭状态保存在 localStorage 中,并通过首屏前脚本提前生效,关闭后刷新页面不会再闪现树叶。 * fix: keep autumn leaves toggles in sync Each toggle instance now follows the html attribute via MutationObserver and derives the next state from it, so the desktop and mobile-menu buttons never disagree. 中文:每个开关实例通过 MutationObserver 跟随 html 属性并据此计算下一状态,桌面端与移动端菜单中的按钮始终保持一致。
1 parent 4225433 commit e3a1614

6 files changed

Lines changed: 187 additions & 1 deletion

File tree

‎packages/app/src/app/globals.css‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@
153153
}
154154
}
155155

156-
html[data-inferencex-embed] .autumn-leaves {
156+
html[data-inferencex-embed] .autumn-leaves,
157+
html[data-autumn-leaves-off] .autumn-leaves {
157158
display: none;
158159
}
159160

‎packages/app/src/app/layout.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Header } from '@/components/header/header';
1212
import { RouteTransition } from '@/components/motion/route-transition';
1313
import { JsonLd } from '@/components/json-ld';
1414
import { AutumnLeaves } from '@/components/autumn-leaves';
15+
import { autumnLeavesPrepaintScript } from '@/lib/autumn-leaves';
1516
import { CircuitBackground } from '@/components/circuit-background';
1617
import { MinecraftBackgroundLazy } from '@/components/minecraft/minecraft-background-lazy';
1718
import { MinecraftDecorations } from '@/components/minecraft/minecraft-decorations';
@@ -211,6 +212,11 @@ export default async function RootLayout({
211212
suppressHydrationWarning
212213
dangerouslySetInnerHTML={{ __html: landingBannerPrepaintScript }}
213214
/>
215+
<script
216+
id="autumn-leaves-prepaint"
217+
suppressHydrationWarning
218+
dangerouslySetInnerHTML={{ __html: autumnLeavesPrepaintScript }}
219+
/>
214220
<CircuitBackground />
215221
<MinecraftBackgroundLazy />
216222
<MinecraftDecorations />
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// @vitest-environment jsdom
2+
import { act } from 'react';
3+
import { createRoot, type Root } from 'react-dom/client';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
vi.mock('@/lib/analytics', () => ({ track: vi.fn() }));
7+
8+
import { AutumnLeavesToggle } from './autumn-leaves-toggle';
9+
import {
10+
AUTUMN_LEAVES_OFF_ATTRIBUTE,
11+
AUTUMN_LEAVES_STORAGE_KEY,
12+
autumnLeavesPrepaintScript,
13+
} from '@/lib/autumn-leaves';
14+
15+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16+
17+
describe('AutumnLeavesToggle', () => {
18+
let container: HTMLDivElement;
19+
let root: Root;
20+
21+
beforeEach(() => {
22+
localStorage.clear();
23+
document.documentElement.removeAttribute(AUTUMN_LEAVES_OFF_ATTRIBUTE);
24+
container = document.createElement('div');
25+
document.body.append(container);
26+
root = createRoot(container);
27+
});
28+
29+
afterEach(() => {
30+
act(() => root.unmount());
31+
container.remove();
32+
});
33+
34+
const button = () =>
35+
container.querySelector<HTMLButtonElement>('[data-testid="autumn-leaves-toggle"]')!;
36+
37+
it('hides the leaves, persists the choice, and turns them back on', () => {
38+
act(() => root.render(<AutumnLeavesToggle />));
39+
expect(button().getAttribute('aria-pressed')).toBe('true');
40+
41+
act(() => button().click());
42+
expect(document.documentElement.hasAttribute(AUTUMN_LEAVES_OFF_ATTRIBUTE)).toBe(true);
43+
expect(localStorage.getItem(AUTUMN_LEAVES_STORAGE_KEY)).toBe('off');
44+
expect(button().getAttribute('aria-pressed')).toBe('false');
45+
expect(button().getAttribute('aria-label')).toBe('Show falling leaves');
46+
47+
act(() => button().click());
48+
expect(document.documentElement.hasAttribute(AUTUMN_LEAVES_OFF_ATTRIBUTE)).toBe(false);
49+
expect(localStorage.getItem(AUTUMN_LEAVES_STORAGE_KEY)).toBeNull();
50+
});
51+
52+
it('keeps every mounted instance in sync', async () => {
53+
act(() =>
54+
root.render(
55+
<>
56+
<AutumnLeavesToggle />
57+
<AutumnLeavesToggle />
58+
</>,
59+
),
60+
);
61+
const [first, second] = container.querySelectorAll<HTMLButtonElement>(
62+
'[data-testid="autumn-leaves-toggle"]',
63+
);
64+
act(() => first.click());
65+
// MutationObserver callbacks run as microtasks.
66+
await act(async () => {
67+
await Promise.resolve();
68+
});
69+
expect(second.getAttribute('aria-pressed')).toBe('false');
70+
act(() => second.click());
71+
expect(document.documentElement.hasAttribute(AUTUMN_LEAVES_OFF_ATTRIBUTE)).toBe(false);
72+
await act(async () => {
73+
await Promise.resolve();
74+
});
75+
expect(first.getAttribute('aria-pressed')).toBe('true');
76+
});
77+
78+
it('prepaint script keys off the saved opt-out', () => {
79+
expect(autumnLeavesPrepaintScript).toContain(JSON.stringify(AUTUMN_LEAVES_STORAGE_KEY));
80+
expect(autumnLeavesPrepaintScript).toContain(JSON.stringify(AUTUMN_LEAVES_OFF_ATTRIBUTE));
81+
});
82+
83+
it('reads the opt-out the prepaint script applied before hydration', () => {
84+
document.documentElement.setAttribute(AUTUMN_LEAVES_OFF_ATTRIBUTE, '');
85+
86+
act(() => root.render(<AutumnLeavesToggle isZh />));
87+
expect(button().getAttribute('aria-pressed')).toBe('false');
88+
expect(button().getAttribute('aria-label')).toBe('显示飘落的树叶');
89+
});
90+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
'use client';
2+
3+
import { Leaf } from 'lucide-react';
4+
import { useEffect, useState } from 'react';
5+
6+
import { HEADER_ACTION_STYLE } from '@/components/ui/control-styles';
7+
import { track } from '@/lib/analytics';
8+
import {
9+
AUTUMN_LEAVES_OFF_ATTRIBUTE,
10+
AUTUMN_LEAVES_OFF_VALUE,
11+
AUTUMN_LEAVES_STORAGE_KEY,
12+
} from '@/lib/autumn-leaves';
13+
import { cn } from '@/lib/utils';
14+
15+
/** Header button that shows or hides the falling autumn leaves; the choice persists. */
16+
export function AutumnLeavesToggle({ isZh = false }: { isZh?: boolean }) {
17+
const [enabled, setEnabled] = useState(true);
18+
19+
// The header can mount two instances (desktop and mobile menu), so every
20+
// instance follows the `<html>` attribute instead of owning the state.
21+
useEffect(() => {
22+
const root = document.documentElement;
23+
const sync = () => setEnabled(!root.hasAttribute(AUTUMN_LEAVES_OFF_ATTRIBUTE));
24+
sync();
25+
const observer = new MutationObserver(sync);
26+
observer.observe(root, { attributes: true, attributeFilter: [AUTUMN_LEAVES_OFF_ATTRIBUTE] });
27+
return () => observer.disconnect();
28+
}, []);
29+
30+
const toggle = () => {
31+
const root = document.documentElement;
32+
const next = root.hasAttribute(AUTUMN_LEAVES_OFF_ATTRIBUTE);
33+
setEnabled(next);
34+
if (next) root.removeAttribute(AUTUMN_LEAVES_OFF_ATTRIBUTE);
35+
else root.setAttribute(AUTUMN_LEAVES_OFF_ATTRIBUTE, '');
36+
try {
37+
if (next) localStorage.removeItem(AUTUMN_LEAVES_STORAGE_KEY);
38+
else localStorage.setItem(AUTUMN_LEAVES_STORAGE_KEY, AUTUMN_LEAVES_OFF_VALUE);
39+
} catch {
40+
// Storage can be unavailable (private mode); the toggle still works for this page view.
41+
}
42+
track('autumn_leaves_toggled', { enabled: next });
43+
};
44+
45+
const label = enabled
46+
? isZh
47+
? '隐藏飘落的树叶'
48+
: 'Hide falling leaves'
49+
: isZh
50+
? '显示飘落的树叶'
51+
: 'Show falling leaves';
52+
53+
return (
54+
<button
55+
type="button"
56+
data-testid="autumn-leaves-toggle"
57+
className={cn(HEADER_ACTION_STYLE, 'size-11', !enabled && 'opacity-60')}
58+
aria-label={label}
59+
aria-pressed={enabled}
60+
title={label}
61+
onClick={toggle}
62+
>
63+
<span className="relative">
64+
<Leaf size={18} aria-hidden="true" className={enabled ? 'text-orange-600' : undefined} />
65+
{!enabled && (
66+
<span className="absolute inset-0 flex items-center justify-center" aria-hidden="true">
67+
<span className="block h-[2px] w-[22px] rotate-45 bg-current" />
68+
</span>
69+
)}
70+
</span>
71+
</button>
72+
);
73+
}

‎packages/app/src/components/header/header.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { usePathname, useRouter } from 'next/navigation';
66
import { useCallback, useEffect, useRef, useState } from 'react';
77
import { track } from '@/lib/analytics';
88

9+
import { AutumnLeavesToggle } from '@/components/autumn-leaves-toggle';
910
import { ModeToggle } from '@/components/ui/mode-toggle';
1011
import { HEADER_ACTION_STYLE } from '@/components/ui/control-styles';
1112
import { NewBadge } from '@/components/ui/new-badge';
@@ -268,6 +269,9 @@ export const Header = ({ starCount }: { starCount?: number | null }) => {
268269
<span className="hidden items-center gap-2 sm:flex">
269270
<MinecraftToggles />
270271
</span>
272+
<span className="hidden sm:flex">
273+
<AutumnLeavesToggle isZh={isZh} />
274+
</span>
271275
<ModeToggle />
272276

273277
{/* Mobile hamburger */}
@@ -335,6 +339,7 @@ export const Header = ({ starCount }: { starCount?: number | null }) => {
335339
</Link>
336340
))}
337341
<span className="flex items-center gap-2 px-3 sm:hidden">
342+
<AutumnLeavesToggle isZh={isZh} />
338343
<MinecraftToggles />
339344
</span>
340345
</div>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/** Persisted opt-out for the October falling-leaves decoration. */
2+
export const AUTUMN_LEAVES_STORAGE_KEY = 'inferencex-autumn-leaves';
3+
export const AUTUMN_LEAVES_OFF_VALUE = 'off';
4+
/** Set on `<html>` while the leaves are hidden, before first paint when possible. */
5+
export const AUTUMN_LEAVES_OFF_ATTRIBUTE = 'data-autumn-leaves-off';
6+
7+
export const autumnLeavesPrepaintScript = `try{if(localStorage.getItem(${JSON.stringify(
8+
AUTUMN_LEAVES_STORAGE_KEY,
9+
)})===${JSON.stringify(AUTUMN_LEAVES_OFF_VALUE)}){document.documentElement.setAttribute(${JSON.stringify(
10+
AUTUMN_LEAVES_OFF_ATTRIBUTE,
11+
)},'')}}catch{}`;

0 commit comments

Comments
 (0)