Skip to content

Commit c8b34c2

Browse files
feat(theme): add DOOM theme
Adds a decorative DOOM option to the theme picker (Skull icon). Lazy-loads a local DOOM (2016) hellscape background and the classic DOOM wordmark banner, keeps the dark chart, vendor, high-contrast, and overlay palettes, and hides decorations on embeds. No game, audio, or custom fonts. 中文:新增 DOOM 主题 在主题选择器中新增装饰性 DOOM 选项(骷髅图标)。按需加载本地 DOOM (2016) 地狱背景和经典 DOOM 标志横幅,沿用深色图表、厂商、高对比度和叠加配色,嵌入页面不显示装饰。不包含游戏、音频或自定义字体。
1 parent 31937cd commit c8b34c2

20 files changed

Lines changed: 338 additions & 24 deletions

‎docs/themes.md‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ contrast branch.
4040
## CS:GO implementation
4141

4242
`src/lib/themes.ts` centralizes picker order
43-
(`light, dark, minecraft, csgo, gta, kart`), dark-background classification, and the
43+
(`light, dark, minecraft, csgo, gta, kart, doom`), dark-background classification, and the
4444
chart-palette alias used by CS:GO and GTA. CS:GO retains the
4545
existing sans-serif font for chart legibility and preserves the dark theme's
4646
vendor, high-contrast, and overlay palettes. It does not recolor hardware
@@ -104,6 +104,21 @@ and the presentation-only API exclusion. Asset provenance and the
104104
maintainer-reported Nintendo permission are recorded in
105105
`packages/app/public/decorative/kart/README.md`.
106106

107+
## Doom implementation
108+
109+
Doom follows the CS:GO pattern with no game, audio, Three.js, or custom fonts.
110+
It is dark-classified (`isDarkTheme`), aliases onto the dark chart seed
111+
(`chartPaletteTheme`), and keeps the dark vendor, high-contrast, and overlay
112+
palettes. The picker uses the `Skull` icon.
113+
114+
`DoomDecorations` follows the root class and renders a responsive local DOOM
115+
(2016) hellscape behind the document (`aria-hidden`, `pointer-events: none`)
116+
under a dark ember shade. `DoomThemeBanner` shows the classic DOOM wordmark with
117+
an `E1M1: HANGAR` eyebrow. Cards stay nearly opaque with a hellfire-orange top
118+
rule; circuit and autumn decorations are hidden. Embed routes suppress both.
119+
Asset provenance and the maintainer-reported permission are recorded in
120+
`packages/app/public/decorative/doom/README.md`.
121+
107122
## Picker accessibility
108123

109124
The GTA banner also launches the optional [Bay Area heist game](./gta-heist.md).

‎packages/app/cypress/component/mode-toggle.cy.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const ICONS: Record<string, string> = {
99
csgo: 'lucide-crosshair',
1010
gta: 'lucide-car',
1111
kart: 'lucide-flag',
12+
doom: 'lucide-skull',
1213
};
1314

1415
function pick(theme: string) {
@@ -51,7 +52,7 @@ describe('ModeToggle', () => {
5152
});
5253

5354
it('selects any theme directly and closes the menu', () => {
54-
for (const theme of ['kart', 'gta', 'minecraft', 'dark', 'csgo', 'light']) {
55+
for (const theme of ['kart', 'doom', 'gta', 'minecraft', 'dark', 'csgo', 'light']) {
5556
pick(theme);
5657
cy.get('html').should('have.class', theme);
5758
cy.get('[data-testid="theme-menu"]').should('not.exist');

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const featureCode =
2-
/THREE\.WebGLRenderer|WebGLRenderer:|LOS SANTOS 3D|minecraft-click\.mp3|ender-dragon\.mp3|Loading Luigi Circuit|inferencex-minecraft-worlds|mc-panorama-cube|\.mc-hotbar-wrap|\.csgo-scene|\.gta-scene|\.mc-dragon-flyacross|\.kart-scene|font-family:\s*["']?(?:Monocraft|Pricedown|ChaletComprime)/i;
2+
/THREE\.WebGLRenderer|WebGLRenderer:|LOS SANTOS 3D|minecraft-click\.mp3|ender-dragon\.mp3|Loading Luigi Circuit|inferencex-minecraft-worlds|mc-panorama-cube|\.mc-hotbar-wrap|\.csgo-scene|\.gta-scene|\.mc-dragon-flyacross|\.kart-scene|\.doom-scene|font-family:\s*["']?(?:Monocraft|Pricedown|ChaletComprime)/i;
33
const featureAsset =
4-
/\/decorative\/(?:minecraft|csgo|gta|kart)\/|minecraft-click\.mp3|Monocraft-|Pricedown|ChaletComprime|youtube(?:-nocookie)?\.com|ytimg\.com/i;
4+
/\/decorative\/(?:minecraft|csgo|gta|kart|doom)\/|minecraft-click\.mp3|Monocraft-|Pricedown|ChaletComprime|youtube(?:-nocookie)?\.com|ytimg\.com/i;
55
const featureElements =
66
'[data-testid="minecraft-game"], [data-testid="minecraft-play-banner"], [data-testid="kart-game"], [data-testid$="-theme-banner"], .mc-dragon-flyacross';
77
const isFeatureAsset = (url: string) => featureAsset.test(decodeURIComponent(url));
@@ -71,7 +71,7 @@ describe('optional themes stay off the default page', () => {
7171
});
7272
}
7373

74-
for (const theme of ['minecraft', 'csgo', 'gta', 'kart']) {
74+
for (const theme of ['minecraft', 'csgo', 'gta', 'kart', 'doom']) {
7575
for (const prefix of ['', '/zh']) {
7676
it(`${prefix || 'English'} embed ignores saved ${theme} before and after hydration`, () => {
7777
const requests: string[] = [];
@@ -117,7 +117,7 @@ describe('optional themes stay off the default page', () => {
117117
).to.have.length(0);
118118
expect(
119119
doc.querySelector(
120-
'[data-testid="kart-game"], [data-testid="gta-theme-banner"], [data-testid="csgo-theme-banner"]',
120+
'[data-testid="kart-game"], [data-testid="gta-theme-banner"], [data-testid="csgo-theme-banner"], [data-testid="doom-theme-banner"]',
121121
),
122122
).to.equal(null);
123123
// Metadata and indexable headings are in raw HTML, without running JavaScript.
@@ -144,7 +144,7 @@ describe('optional themes stay off the default page', () => {
144144
.and('contain', 'currently');
145145
cy.document().then((doc) => {
146146
const baseline = seo(doc);
147-
for (const theme of ['csgo', 'gta', 'minecraft', 'kart']) {
147+
for (const theme of ['csgo', 'gta', 'minecraft', 'kart', 'doom']) {
148148
cy.get('[data-testid="theme-toggle"]').click();
149149
cy.get(`[data-testid="theme-option-${theme}"]`).click();
150150
if (theme === 'minecraft') cy.get('canvas').should('exist');

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,39 @@ describe('Theme Toggle', () => {
174174
cy.get('[data-testid="csgo-theme-banner"]').should('not.exist');
175175
});
176176

177+
it('loads Doom on demand, persists it, and removes decorations on exit', () => {
178+
cy.visit('/', {
179+
onBeforeLoad(win) {
180+
win.localStorage.setItem('theme', 'light');
181+
win.localStorage.setItem('minecraft-music', 'false');
182+
win.localStorage.setItem('minecraft-sound', 'false');
183+
},
184+
});
185+
cy.get('[data-testid="doom-scene"]').should('not.exist');
186+
cy.window().then((win) => {
187+
expect(
188+
win.performance
189+
.getEntriesByType('resource')
190+
.some((r) => r.name.includes('/decorative/doom/')),
191+
).to.eq(false);
192+
});
193+
pickTheme('doom');
194+
cy.get('html').should('have.class', 'doom');
195+
cy.get('[data-testid="doom-theme-banner"]').should('be.visible');
196+
cy.get('[data-testid="doom-scene"]')
197+
.should('have.attr', 'aria-hidden', 'true')
198+
.and('have.css', 'pointer-events', 'none');
199+
cy.reload();
200+
cy.get('html').should('have.class', 'doom');
201+
cy.get('[data-testid="doom-scene"] img').should(($img) => {
202+
expect(($img[0] as HTMLImageElement).naturalWidth).to.be.greaterThan(0);
203+
});
204+
pickTheme('light');
205+
cy.get('html').should('have.class', 'light');
206+
cy.get('[data-testid="doom-scene"]').should('not.exist');
207+
cy.get('[data-testid="doom-theme-banner"]').should('not.exist');
208+
});
209+
177210
it('applies GTA from the picker, persists it, and removes decorations on exit', () => {
178211
cy.visit('/', {
179212
onBeforeLoad(win) {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# DOOM artwork
2+
3+
id Software / Bethesda image assets, included under the permission reported by
4+
the requesting maintainer on 2026-10-07. This note records that representation,
5+
not an independently verified license or permission for audio, video, fonts, or
6+
other assets. The repository's code license does not relicense this artwork.
7+
8+
- `doom-hell.webp`: WebP-encoded from the official DOOM (2016)
9+
[Steam store screenshot](https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/379720/ss_d7ee83faf64036494e4a817b8be55b044fbe7c6c.1920x1080.jpg)
10+
(1920x1051 source).
11+
- `doom-hell-mobile.webp`: 640x1000 center crop of the same screenshot.
12+
- `doom-logo.webp`: lossless WebP crop of the classic DOOM wordmark from the
13+
official DOOM + DOOM II [Steam library logo](https://cdn.akamai.steamstatic.com/steam/apps/2280/logo.png).
14+
15+
Assets are hosted locally and requested only when the Doom theme is active.
16+
There are no third-party embeds, audio tracks, or external runtime requests.
54.2 KB
Loading
187 KB
Loading
85.9 KB
Loading

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

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
@import './motion.css';
44
@plugin '@tailwindcss/typography';
55

6-
@custom-variant dark (&:is(.dark *, .minecraft *, .csgo *, .gta *, .kart *));
6+
@custom-variant dark (&:is(.dark *, .minecraft *, .csgo *, .gta *, .kart *, .doom *));
77
@custom-variant minecraft (&:is(.minecraft *));
88

99
/* Allow hash navigation to clear the fixed navbar */
@@ -54,11 +54,11 @@
5454
color: #7e22ce;
5555
}
5656

57-
:is(.dark, .minecraft, .csgo, .gta, .kart) .halloween-wordmark {
57+
:is(.dark, .minecraft, .csgo, .gta, .kart, .doom) .halloween-wordmark {
5858
color: #fb923c;
5959
}
6060

61-
:is(.dark, .minecraft, .csgo, .gta, .kart) .halloween-wordmark-x {
61+
:is(.dark, .minecraft, .csgo, .gta, .kart, .doom) .halloween-wordmark-x {
6262
color: #c084fc;
6363
}
6464

@@ -302,7 +302,8 @@ html[data-inferencex-embed] .autumn-leaves {
302302
.dark,
303303
.csgo,
304304
.gta,
305-
.kart {
305+
.kart,
306+
.doom {
306307
/* Base Theme Colors */
307308
--background: #131416;
308309
--foreground: #eaebec;
@@ -362,6 +363,27 @@ html[data-inferencex-embed] .autumn-leaves {
362363
display: none;
363364
}
364365

366+
/* Tiny pre-paint shell for persisted Doom selections. Artwork stays lazy. */
367+
.doom {
368+
color-scheme: dark;
369+
--background: #140b09;
370+
--foreground: #f1e6dc;
371+
--card: #1d110d;
372+
--card-foreground: #f1e6dc;
373+
--popover: #22140f;
374+
--popover-foreground: #f1e6dc;
375+
--primary: #ff6a2b;
376+
--primary-foreground: #140b09;
377+
--muted: #33201a;
378+
--muted-foreground: #c9b6aa;
379+
--accent: #3a2219;
380+
--ring: #ffb347;
381+
}
382+
.doom .circuit-bg,
383+
.doom .autumn-leaves {
384+
display: none;
385+
}
386+
365387
/* Scoped pre-paint palettes for persisted themes. No assets or animation. */
366388
.csgo {
367389
color-scheme: dark;
@@ -494,7 +516,9 @@ html[data-inferencex-embed] .autumn-leaves {
494516
.gta .shiki,
495517
.gta .shiki span,
496518
.kart .shiki,
497-
.kart .shiki span {
519+
.kart .shiki span,
520+
.doom .shiki,
521+
.doom .shiki span {
498522
color: var(--shiki-dark);
499523
background-color: var(--shiki-dark-bg);
500524
}
@@ -610,7 +634,8 @@ html[data-inferencex-skin='vllm'] [data-slot='card'] {
610634
/* Dark backgrounds need no outline — this is the original title-screen look. */
611635
.dark .splash-text,
612636
.minecraft .splash-text,
613-
.csgo .splash-text {
637+
.csgo .splash-text,
638+
.doom .splash-text {
614639
color: #ffff00;
615640
text-shadow: 2px 2px 0px #3f3f00;
616641
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// @vitest-environment jsdom
2+
import React, { act } from 'react';
3+
import { createRoot, type Root } from 'react-dom/client';
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5+
import { DoomDecorations } from './doom-decorations';
6+
7+
let container: HTMLDivElement;
8+
let root: Root;
9+
10+
beforeEach(() => {
11+
document.documentElement.className = 'dark';
12+
container = document.createElement('div');
13+
document.body.append(container);
14+
root = createRoot(container);
15+
});
16+
17+
afterEach(() => {
18+
act(() => root.unmount());
19+
container.remove();
20+
document.documentElement.className = '';
21+
});
22+
23+
async function setTheme(theme: string) {
24+
await act(async () => {
25+
document.documentElement.className = theme;
26+
await Promise.resolve();
27+
});
28+
}
29+
30+
describe('DoomDecorations', () => {
31+
it('does not mount request-bearing images in the other themes', async () => {
32+
act(() => root.render(<DoomDecorations />));
33+
for (const theme of ['light', 'dark', 'minecraft', 'csgo', 'gta', 'kart']) {
34+
await setTheme(theme);
35+
expect(container.querySelector('img')).toBeNull();
36+
}
37+
});
38+
39+
it('loads on activation, stays decorative, and unmounts on exit', async () => {
40+
act(() => root.render(<DoomDecorations />));
41+
await setTheme('doom');
42+
expect(container.querySelector('[data-testid="doom-scene"]')?.getAttribute('aria-hidden')).toBe(
43+
'true',
44+
);
45+
expect(container.querySelector('img')?.getAttribute('src')).toBe(
46+
'/decorative/doom/doom-hell.webp',
47+
);
48+
expect(container.querySelector('source')?.getAttribute('media')).toBe('(max-width: 640px)');
49+
expect(container.querySelectorAll('button, a, audio, video, iframe, canvas')).toHaveLength(0);
50+
await setTheme('light');
51+
expect(container.childElementCount).toBe(0);
52+
await setTheme('doom');
53+
expect(container.querySelectorAll('img')).toHaveLength(1);
54+
});
55+
56+
it('honors a saved Doom theme on mount', () => {
57+
document.documentElement.className = 'doom';
58+
act(() => root.render(<DoomDecorations />));
59+
expect(container.querySelector('img')).not.toBeNull();
60+
});
61+
});

0 commit comments

Comments
 (0)