Skip to content

Commit c8d00f9

Browse files
fix(theme): restore single-button theme toggle / 恢复单按钮主题切换 (#1306)
* fix(theme): restore single-button theme cycling 中文:将主题下拉选择器恢复为单按钮循环切换,保留全部主题、偏好持久化和埋点,并更新回归测试。 * test(theme): adapt retired preference regression to cycling 中文:更新退役主题迁移测试,先验证默认页面资源隔离,再通过切换按钮循环到浅色主题。
1 parent 102ed12 commit c8d00f9

12 files changed

Lines changed: 104 additions & 221 deletions

File tree

‎docs/index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Design rationale and non-obvious conventions. See [CLAUDE.md](../CLAUDE.md) for
44

55
## Docs
66

7-
- [Presentation Themes](./themes.md): Minecraft activation and assets, CS:GO, GTA and Halo styling, Halo theme music, icon-only theme picker, dark-chart/export integration, and presentation-only API exclusion.
7+
- [Presentation Themes](./themes.md): Minecraft activation and assets, CS:GO, GTA and Halo styling, Halo theme music, single-button theme toggle, dark-chart/export integration, and presentation-only API exclusion.
88
- [Playable Minecraft](./minecraft-game.md): the opt-in game launched from the Minecraft theme, controls, saves, lifecycle and verification.
99

1010
- [Dashboard Read-only Views](./dashboard-readonly-views.md): API/filter inventory, source audit and verification scope

‎docs/themes.md‎

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44

55
The root `ThemeProvider` in `packages/app/src/app/layout.tsx` uses `next-themes`
66
to persist a theme and apply its class to `<html>` before hydration. The
7-
header's `ModeToggle` is an icon-only picker: the trigger shows the active
8-
theme's icon and expands a compact column with one icon button per theme in
9-
`APP_THEMES` order, so any theme is one tap away. Selecting a theme emits
10-
`theme_toggled`.
7+
header's `ModeToggle` is a single icon button: each click advances through
8+
`APP_THEMES` in order and wraps from Halo back to light. The icon shows the
9+
active theme, and each change emits `theme_toggled`. There is no dropdown.
1110
The existing default remains dark; the system setting remains supported.
1211

1312
The root `EasterEggThemeLazy` boundary imports only the selected optional theme.
@@ -39,7 +38,7 @@ contrast branch.
3938

4039
## CS:GO implementation
4140

42-
`src/lib/themes.ts` centralizes picker order
41+
`src/lib/themes.ts` centralizes toggle order
4342
(`light, dark, minecraft, csgo, gta, doom, halo`), dark-background classification, and the
4443
chart-palette alias used by CS:GO, GTA, DOOM and Halo. CS:GO retains the
4544
existing sans-serif font for chart legibility and preserves the dark theme's
@@ -91,7 +90,7 @@ by the repository's code license.
9190

9291
Halo follows the GTA pattern: it is dark-classified everywhere (`isDarkTheme`),
9392
aliases onto the dark chart seed (`chartPaletteTheme`), and keeps the dark
94-
vendor, high-contrast, and overlay palettes. The picker shows it with a shield
93+
vendor, high-contrast, and overlay palettes. The toggle shows it with a shield
9594
icon after DOOM. There is no playable game.
9695

9796
The chrome follows the Halo Infinite menus: deep navy near-opaque panels over
@@ -128,7 +127,7 @@ Asset provenance is recorded in `packages/app/public/decorative/minecraft/game/R
128127
Doom follows the CS:GO pattern with no game, audio, Three.js, or custom fonts.
129128
It is dark-classified (`isDarkTheme`), aliases onto the dark chart seed
130129
(`chartPaletteTheme`), and keeps the dark vendor, high-contrast, and overlay
131-
palettes. The picker uses the `Skull` icon.
130+
palettes. The toggle uses the `Skull` icon.
132131

133132
`DoomDecorations` follows the root class and renders a responsive local DOOM
134133
(2016) hellscape behind the document (`aria-hidden`, `pointer-events: none`)
@@ -138,16 +137,16 @@ rule; circuit and autumn decorations are hidden. Embed routes suppress both.
138137
Asset provenance and the maintainer-reported permission are recorded in
139138
`packages/app/public/decorative/doom/README.md`.
140139

141-
## Picker accessibility
140+
## Toggle accessibility
142141

143142
The GTA banner also launches the optional [Bay Area heist game](./gta-heist.md).
144143
The game loads on demand and does not alter benchmark controls or data.
145144

146145
The trigger keeps `data-testid="theme-toggle"` and its
147-
`Switch theme (currently <theme> mode)` label. Options are `role="radio"` in a
148-
labelled `radiogroup` (`data-testid="theme-option-<theme>"`), with localized
149-
`aria-label`/`title` text, roving tab index, arrow/Home/End navigation, and
150-
Escape to close. Options stay 44px on phones and 36px from `md`.
146+
`Switch theme (currently <theme> mode)` label. It is a native button with
147+
Enter/Space activation and a 44px target on desktop and mobile. Keyboard focus
148+
stays on the button after each change. An unknown or system preference advances
149+
to light, matching the original toggle behavior; saved themes remain supported.
151150

152151
## Data/API coverage
153152

@@ -174,7 +173,7 @@ are still checked. Dynamic imports are verified separately by browser tests.
174173
and Firefox CI matrix. It covers:
175174

176175
- English/Chinese desktop/mobile light/dark landings, no saved preference and
177-
system preference, including opening the picker and scrolling.
176+
system preference, including focusing the toggle and scrolling.
178177
- Network requests from navigation onward (including requests still in flight),
179178
loaded JS/CSS fingerprints for the optional engines and Minecraft game,
180179
font registration, and absence of optional theme/game DOM.
@@ -199,7 +198,7 @@ Crawler and theme-switch comparisons also cover robots, Open Graph and Twitter
199198
metadata. Run these tests on the implementation's final commit before merge;
200199
passing an earlier commit does not verify later changes.
201200

202-
- Direct selection of every theme from the picker, keyboard navigation,
201+
- Cycling through every theme and wrapping to light, keyboard activation,
203202
persistence after reload, and cleanup on exit.
204203
- No CS:GO image requests on a cold light/dark landing; images load only
205204
after selection, with a smaller mobile crop.

‎packages/app/cypress/component/header.cy.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Header } from '@/components/header/header';
66
import { ThemeProvider } from '@/components/ui/theme-provider';
77
import { APP_THEMES } from '@/lib/themes';
88
import { createMockRouter } from '../support/mock-router';
9+
import { cycleToTheme } from '../support/theme';
910

1011
// Mounted outside the Next app shell; next-style-loader inserts the global
1112
// stylesheet before this anchor, so it must exist before the import below.
@@ -270,7 +271,6 @@ describe('Header', () => {
270271
const next = APP_THEMES[index + 1];
271272
if (next) {
272273
cy.get('[data-testid="theme-toggle"]').click();
273-
cy.get(`[data-testid="theme-option-${next}"]`).click();
274274
}
275275
}
276276
});
@@ -390,8 +390,7 @@ describe('Header', () => {
390390
});
391391

392392
it('exposes the minecraft audio toggles in the mobile menu without overflowing', () => {
393-
cy.get('[data-testid="theme-toggle"]').click();
394-
cy.get('[data-testid="theme-option-minecraft"]').click();
393+
cycleToTheme('minecraft');
395394
cy.get('html').should('have.class', 'minecraft');
396395
cy.get('[data-testid="mobile-menu-toggle"]').click();
397396
cy.get('[data-testid="mobile-menu"]').within(() => {
Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ModeToggle } from '@/components/ui/mode-toggle';
22
import { ThemeProvider } from '@/components/ui/theme-provider';
33
import { APP_THEMES } from '@/lib/themes';
4+
import { registerAnalyticsClient } from '@/lib/analytics';
45

56
const ICONS: Record<string, string> = {
67
light: 'lucide-sun',
@@ -12,13 +13,10 @@ const ICONS: Record<string, string> = {
1213
halo: 'lucide-shield',
1314
};
1415

15-
function pick(theme: string) {
16-
cy.get('[data-testid="theme-toggle"]').click();
17-
cy.get(`[data-testid="theme-option-${theme}"]`).click();
18-
}
19-
2016
describe('ModeToggle', () => {
2117
beforeEach(() => {
18+
cy.window().then((win) => win.localStorage.setItem('theme', 'light'));
19+
registerAnalyticsClient({ capture: cy.stub().as('capture') });
2220
cy.mount(
2321
<ThemeProvider
2422
attribute="class"
@@ -29,56 +27,36 @@ describe('ModeToggle', () => {
2927
<ModeToggle />
3028
</ThemeProvider>,
3129
);
30+
cy.get('[data-testid="theme-toggle"]').should(
31+
'have.attr',
32+
'aria-label',
33+
'Switch theme (currently light mode)',
34+
);
3235
});
3336

34-
it('expands an icon-only menu with one option per theme', () => {
35-
cy.get('[data-testid="theme-menu"]').should('not.exist');
36-
cy.get('[data-testid="theme-toggle"]')
37-
.should('have.attr', 'aria-expanded', 'false')
38-
.click()
39-
.should('have.attr', 'aria-expanded', 'true');
40-
cy.get('[data-testid="theme-menu"] [role="radio"]').should('have.length', APP_THEMES.length);
41-
cy.get('[data-testid="theme-option-kart"]').should('not.exist');
42-
for (const theme of APP_THEMES) {
43-
cy.get(`[data-testid="theme-option-${theme}"]`)
44-
.should('have.attr', 'aria-label')
45-
.and('not.be.empty');
46-
// Icons only: no visible text inside an option.
47-
cy.get(`[data-testid="theme-option-${theme}"]`).should('have.text', '');
48-
cy.get(`[data-testid="theme-option-${theme}"] svg`).should('have.class', ICONS[theme]);
49-
}
50-
cy.get('[data-testid="theme-option-light"]')
51-
.should('have.attr', 'aria-checked', 'true')
52-
.and('have.focus');
53-
});
54-
55-
it('selects any theme directly and closes the menu', () => {
56-
for (const theme of ['doom', 'halo', 'gta', 'minecraft', 'dark', 'csgo', 'light']) {
57-
pick(theme);
37+
it('cycles every theme with one click and wraps back to light without a menu', () => {
38+
for (const theme of [...APP_THEMES.slice(1), 'light']) {
39+
cy.get('[data-testid="theme-toggle"]').click();
5840
cy.get('html').should('have.class', theme);
5941
cy.get('[data-testid="theme-menu"]').should('not.exist');
42+
cy.get('[data-testid="theme-toggle"]').should('not.have.attr', 'aria-expanded');
6043
cy.get('[data-testid="theme-toggle"]')
6144
.should('have.attr', 'aria-label', `Switch theme (currently ${theme} mode)`)
6245
.find('svg')
6346
.should('have.class', ICONS[theme]);
47+
cy.window().should((win) => expect(win.localStorage.getItem('theme')).to.equal(theme));
48+
cy.get('@capture').should('have.been.calledWith', 'theme_toggled', { theme });
6449
}
65-
cy.get('html').should('not.have.class', 'gta');
50+
cy.get('html').should('not.have.class', 'halo');
6651
});
6752

68-
it('supports arrow-key navigation and Escape', () => {
69-
cy.get('[data-testid="theme-toggle"]').click();
70-
cy.focused().should('have.attr', 'data-testid', 'theme-option-light');
71-
cy.focused().type('{downArrow}');
72-
cy.focused().should('have.attr', 'data-testid', 'theme-option-dark');
73-
cy.focused().type('{upArrow}{upArrow}');
74-
cy.focused().should('have.attr', 'data-testid', `theme-option-${APP_THEMES.at(-1)}`);
75-
cy.focused().click();
76-
cy.get('html').should('have.class', APP_THEMES.at(-1)!);
77-
cy.get('[data-testid="theme-menu"]').should('not.exist');
78-
cy.get('[data-testid="theme-toggle"]').click();
79-
cy.focused().should('have.attr', 'data-testid', `theme-option-${APP_THEMES.at(-1)}`);
80-
cy.focused().type('{esc}');
53+
it('keeps a native button and retains focus after activation', () => {
54+
cy.get('[data-testid="theme-toggle"]')
55+
.should('have.prop', 'tagName', 'BUTTON')
56+
.and('have.attr', 'type', 'button');
57+
cy.get('[data-testid="theme-toggle"]').focus().click();
58+
cy.get('html').should('have.class', 'dark');
59+
cy.focused().should('have.attr', 'data-testid', 'theme-toggle');
8160
cy.get('[data-testid="theme-menu"]').should('not.exist');
82-
cy.get('html').should('have.class', APP_THEMES.at(-1)!);
8361
});
8462
});

‎packages/app/cypress/e2e/landing-performance.cy.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,6 @@ describe('Landing page performance', () => {
192192
.should('have.attr', 'aria-label')
193193
.and('contain', 'currently');
194194
cy.get('[data-testid="theme-toggle"]').click();
195-
cy.get('[data-testid="theme-option-minecraft"]').click();
196195
cy.get('html').should('have.class', 'minecraft');
197196
cy.window().should((win) => {
198197
const resourceNames = win.performance.getEntriesByType('resource').map((entry) => entry.name);

‎packages/app/cypress/e2e/minecraft-game.cy.ts‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
const chooseTheme = (theme: string) => {
2-
cy.get('[data-testid="theme-toggle"]')
3-
.should('have.attr', 'aria-label')
4-
.and('include', 'currently');
5-
cy.get('[data-testid="theme-toggle"]').click();
6-
cy.get(`[data-testid="theme-option-${theme}"]`).click();
7-
};
1+
import { cycleToTheme as chooseTheme } from '../support/theme';
82

93
const gameResources = (win: Window) =>
104
win.performance

‎packages/app/cypress/e2e/optional-theme-isolation.cy.ts‎

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { APP_THEMES } from '../../src/lib/themes';
2+
import { cycleToTheme } from '../support/theme';
23

34
const optionalThemes = APP_THEMES.filter((theme) => !['light', 'dark', 'system'].includes(theme));
45
const featureCode =
@@ -50,7 +51,7 @@ const seo = (doc: Document) => [
5051

5152
describe('optional themes stay off the default page', () => {
5253
for (const route of ['/', '/zh']) {
53-
it(`${route} migrates a retired kart preference and keeps the picker usable`, () => {
54+
it(`${route} migrates a retired kart preference and keeps the toggle usable`, () => {
5455
const requests: string[] = [];
5556
cy.intercept('GET', '**', (request) => {
5657
requests.push(request.url);
@@ -62,17 +63,18 @@ describe('optional themes stay off the default page', () => {
6263
});
6364
cy.get('html').should('have.class', 'dark').and('not.have.class', 'kart');
6465
cy.window().should((win) => expect(win.localStorage.getItem('theme')).to.equal('dark'));
65-
cy.get('[data-testid="theme-toggle"]').click();
66+
cy.get('[data-testid="theme-toggle"]').focus();
6667
cy.get('[data-testid="theme-option-kart"]').should('not.exist');
67-
cy.get('[data-testid="theme-menu"] [role="radio"]').should('have.length', 7);
68-
cy.get('[data-testid="theme-option-light"]').click();
68+
cy.get('[data-testid="theme-menu"]').should('not.exist');
69+
expectNoOptionalResources(requests);
70+
// Cycling intentionally activates optional themes before returning to light.
71+
cycleToTheme('light');
6972
cy.get('html').should('have.class', 'light').and('not.have.class', 'kart');
7073
cy.reload();
7174
cy.get('html').should('have.class', 'light').and('not.have.class', 'kart');
7275
cy.then(() => {
7376
expect(requests.filter((url) => url.includes('/decorative/kart/'))).to.deep.equal([]);
7477
});
75-
expectNoOptionalResources(requests);
7678
});
7779
}
7880

@@ -106,10 +108,9 @@ describe('optional themes stay off the default page', () => {
106108
const splash = win.document.querySelector('.splash-text');
107109
if (splash) expect(win.getComputedStyle(splash).animationName).to.equal('none');
108110
});
109-
// Merely viewing options and interacting with the page must not activate a theme.
110-
cy.get('[data-testid="theme-toggle"]').click();
111-
cy.get('[data-testid="theme-option-minecraft"]').should('be.visible');
112-
cy.get('body').type('{esc}');
111+
// Focusing the toggle must not activate a theme or fetch optional assets.
112+
cy.get('[data-testid="theme-toggle"]').focus();
113+
cy.get('[data-testid="theme-menu"]').should('not.exist');
113114
cy.scrollTo('bottom');
114115
expectNoOptionalResources(requests);
115116
});
@@ -183,13 +184,11 @@ describe('optional themes stay off the default page', () => {
183184
cy.document().then((doc) => {
184185
const baseline = seo(doc);
185186
for (const theme of optionalThemes) {
186-
cy.get('[data-testid="theme-toggle"]').click();
187-
cy.get(`[data-testid="theme-option-${theme}"]`).click();
187+
cycleToTheme(theme);
188188
if (theme === 'minecraft') cy.get('canvas').should('exist');
189189
else cy.get(`[data-testid="${theme}-theme-banner"]`).should('be.visible');
190190
cy.document().should((current) => expect(seo(current)).to.deep.equal(baseline));
191-
cy.get('[data-testid="theme-toggle"]').click();
192-
cy.get('[data-testid="theme-option-dark"]').click();
191+
cycleToTheme('dark');
193192
cy.get(`canvas, audio, iframe, ${featureElements}`).should('not.exist');
194193
cy.document().should((current) => expect(seo(current)).to.deep.equal(baseline));
195194
}

‎packages/app/cypress/e2e/sanity.cy.ts‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,7 @@
11
// Merged from basic.cy.ts, navigation.cy.ts, theme-toggle.cy.ts, and land-acknowledgement.cy.ts
22
// to reduce per-file Cypress startup overhead (~500ms per file)
33

4-
/** Open the header theme picker after the client mount and choose `theme`. */
5-
function pickTheme(theme: string) {
6-
// The trigger label names the saved theme only once the client has mounted.
7-
cy.get('[data-testid="theme-toggle"]')
8-
.should('have.attr', 'aria-label')
9-
.and('contain', 'currently');
10-
cy.get('[data-testid="theme-toggle"]').click();
11-
cy.get(`[data-testid="theme-option-${theme}"]`).click();
12-
}
4+
import { cycleToTheme as pickTheme } from '../support/theme';
135

146
describe('Page Load & Navigation', () => {
157
before(() => {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { APP_THEMES } from '../../src/lib/themes';
2+
3+
/** Reach a theme through the real toggle, waiting for each React update. */
4+
export function cycleToTheme(theme: string, remaining = APP_THEMES.length): void {
5+
expect(APP_THEMES, 'registered target theme').to.include(theme);
6+
cy.get('[data-testid="theme-toggle"]')
7+
.should('have.attr', 'aria-label')
8+
.and('contain', 'currently');
9+
cy.get('html').then(($html) => {
10+
if ($html.hasClass(theme)) return;
11+
expect(remaining, `clicks left to reach ${theme}`).to.be.greaterThan(0);
12+
const current = APP_THEMES.find((candidate) => $html.hasClass(candidate));
13+
cy.get('[data-testid="theme-toggle"]').click();
14+
if (current) cy.get('html').should('not.have.class', current);
15+
cycleToTheme(theme, remaining - 1);
16+
});
17+
}

0 commit comments

Comments
 (0)