feat(theme): add halloween special theme

This commit is contained in:
Tommaso Casaburi
2026-05-29 17:45:18 +07:00
parent e082c7b428
commit 0469f61a45
14 changed files with 301 additions and 35 deletions
+18
View File
@@ -29,6 +29,7 @@ const testState = vi.hoisted(() => ({
initSnowMock: vi.fn(),
isMobile: false,
isSpecialEnabled: false,
shouldShowSnow: true,
removeSnowMock: vi.fn(),
replyModalState: {
activeCid: null,
@@ -104,6 +105,7 @@ vi.mock('../stores/use-special-theme-store', () => ({
vi.mock('../lib/snow', () => ({
initSnow: (options: unknown) => testState.initSnowMock(options),
removeSnow: () => testState.removeSnowMock(),
shouldShowSnow: () => testState.shouldShowSnow,
}));
vi.mock('../lib/utils/preload-utils', () => ({
@@ -339,6 +341,7 @@ describe('App', () => {
];
testState.isMobile = false;
testState.isSpecialEnabled = false;
testState.shouldShowSnow = true;
testState.replyModalState = {
activeCid: null,
closeModal: vi.fn(),
@@ -542,4 +545,19 @@ describe('App', () => {
root = createRoot(container);
});
it('does not start snow for non-christmas special themes', async () => {
testState.isSpecialEnabled = true;
testState.shouldShowSnow = false;
await renderApp('/mu');
expect(testState.initSnowMock).not.toHaveBeenCalled();
expect(testState.closeCreateBoardModalMock).toHaveBeenCalledTimes(1);
act(() => root.unmount());
expect(testState.removeSnowMock).toHaveBeenCalled();
root = createRoot(container);
});
});
+3 -3
View File
@@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { useAccount, useCommunity } from '@bitsocial/bitsocial-react-hooks';
import { initSnow, removeSnow } from './lib/snow';
import { initSnow, removeSnow, shouldShowSnow } from './lib/snow';
import { isAllView, isCatalogView, isModView, isSubscriptionsView } from './lib/utils/view-utils';
import { preloadReplyModal, preloadThemeAssets } from './lib/utils/preload-utils';
import { hasModQueueAccessRole } from './lib/utils/mod-access';
@@ -89,10 +89,10 @@ const BoardLayout = () => {
const shouldRenderOutlet = isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute || isOnArchiveRoute || isOnDirectoryRoute;
const shouldRenderBoardBlotter = !isOnArchiveRoute && !isOnDirectoryRoute && !isOnModQueueRoute;
const isInCatalogView = isCatalogView(pathname, params);
// Christmas theme
// Christmas snow
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
useEffect(() => {
if (isSpecialEnabled && !isMobile) {
if (isSpecialEnabled && shouldShowSnow() && !isMobile) {
initSnow({ flakeCount: 150 });
}
return () => {
@@ -1,27 +1,25 @@
import { useEffect } from 'react';
import useTheme from '../../hooks/use-theme';
import useSpecialThemeStore from '../../stores/use-special-theme-store';
import { isChristmas } from '../../lib/utils/time-utils';
import { getActiveSpecialTheme } from '../../lib/utils/time-utils';
import styles from './style-selector.module.css';
const StyleSelector = () => {
const [theme, setTheme] = useTheme();
const { isEnabled, setIsEnabled } = useSpecialThemeStore();
const isChristmasTime = isChristmas();
const activeSpecialTheme = getActiveSpecialTheme();
useEffect(() => {
if (!isChristmasTime && isEnabled) {
if (!activeSpecialTheme && isEnabled) {
setIsEnabled(false);
setTheme('tomorrow');
}
}, [isChristmasTime, isEnabled, setIsEnabled, setTheme]);
}, [activeSpecialTheme, isEnabled, setIsEnabled]);
const handleThemeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newTheme = e.target.value;
if (newTheme === 'special') {
setIsEnabled(true);
setTheme('tomorrow');
} else {
setIsEnabled(false);
setTheme(newTheme);
@@ -36,7 +34,7 @@ const StyleSelector = () => {
<option value='burichan'>Burichan</option>
<option value='tomorrow'>Tomorrow</option>
<option value='photon'>Photon</option>
{isChristmasTime && <option value='special'>Special</option>}
{activeSpecialTheme && <option value='special'>Special</option>}
</select>
);
};
+9 -10
View File
@@ -5,12 +5,12 @@ import useThemeStore from '../stores/use-theme-store';
import { useDirectories } from './use-directories';
import { useResolvedCommunityAddress } from './use-resolved-community-address';
import useSpecialThemeStore from '../stores/use-special-theme-store';
import { isChristmas } from '../lib/utils/time-utils';
import { getActiveSpecialTheme, getSpecialThemeClass } from '../lib/utils/time-utils';
import { isSfwBoard, updateFavicon } from '../lib/update-favicon';
import useSafeAccountComment from './use-safe-account-comment';
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
const themeClasses = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'tomorrow', 'photon'];
const themeClasses = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'tomorrow', 'photon', 'spooky'];
const updateThemeClass = (newTheme: string) => {
document.body.classList.remove(...themeClasses);
@@ -39,16 +39,15 @@ const useTheme = (): [string, (theme: string) => void] => {
const routeIdentifier = params.boardIdentifier;
const resolvedAddress = useResolvedCommunityAddress();
const communityAddress = resolvedAddress || pendingPostCommunityAddress || routeIdentifier;
const activeSpecialTheme = getActiveSpecialTheme();
useEffect(() => {
const isChristmasTime = isChristmas();
if (isChristmasTime && isEnabled === null && communityAddress && !isInAllView && !isInSubscriptionsView && !isInModView) {
if (activeSpecialTheme && isEnabled === null && communityAddress && !isInAllView && !isInSubscriptionsView && !isInModView) {
setIsEnabled(true);
} else if (!isChristmasTime && isEnabled) {
} else if (!activeSpecialTheme && isEnabled) {
setIsEnabled(false);
}
}, [isEnabled, setIsEnabled, communityAddress, isInAllView, isInSubscriptionsView, isInModView]);
}, [activeSpecialTheme, isEnabled, setIsEnabled, communityAddress, isInAllView, isInSubscriptionsView, isInModView]);
const currentTheme = useMemo(() => {
if (location.pathname === '/') {
@@ -59,8 +58,8 @@ const useTheme = (): [string, (theme: string) => void] => {
return 'yotsuba';
}
if (isEnabled) {
return 'tomorrow';
if (isEnabled && activeSpecialTheme) {
return getSpecialThemeClass(activeSpecialTheme);
}
let storedTheme = null;
@@ -76,7 +75,7 @@ const useTheme = (): [string, (theme: string) => void] => {
}
return storedTheme || 'yotsuba';
}, [location.pathname, isEnabled, isInAllView, isInSubscriptionsView, isInModView, communityAddress, directories, themes]);
}, [location.pathname, isEnabled, activeSpecialTheme, isInAllView, isInSubscriptionsView, isInModView, communityAddress, directories, themes]);
const sfw = isSfwBoard({
pathname: location.pathname,
+11 -3
View File
@@ -26,10 +26,13 @@ describe('snow', () => {
mathRandomSpy.mockRestore();
});
it('prefers the special theme store and otherwise falls back to christmas dates', async () => {
it('shows snow only for christmas special theme dates', async () => {
const useSpecialThemeStore = (await import('../../stores/use-special-theme-store')).default;
const { shouldShowSnow } = await import('../snow');
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-12-24T00:00:00Z'));
useSpecialThemeStore.setState({ isEnabled: true });
expect(shouldShowSnow()).toBe(true);
@@ -37,10 +40,15 @@ describe('snow', () => {
expect(shouldShowSnow()).toBe(false);
useSpecialThemeStore.setState({ isEnabled: null });
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-12-24T00:00:00Z'));
expect(shouldShowSnow()).toBe(true);
vi.setSystemTime(new Date('2024-10-31T00:00:00Z'));
useSpecialThemeStore.setState({ isEnabled: true });
expect(shouldShowSnow()).toBe(false);
useSpecialThemeStore.setState({ isEnabled: null });
expect(shouldShowSnow()).toBe(false);
vi.setSystemTime(new Date('2024-07-04T00:00:00Z'));
expect(shouldShowSnow()).toBe(false);
});
+4 -5
View File
@@ -1,4 +1,5 @@
import useSpecialThemeStore from '../stores/use-special-theme-store';
import { getActiveSpecialTheme } from './utils/time-utils';
interface SnowOptions {
flakeCount: number;
@@ -84,10 +85,11 @@ export const removeSnow = (): void => {
export const shouldShowSnow = (): boolean => {
const isEnabled = useSpecialThemeStore.getState().isEnabled;
const activeSpecialTheme = getActiveSpecialTheme();
// Check store value first
if (isEnabled !== null) {
return isEnabled;
return isEnabled && activeSpecialTheme === 'christmas';
}
// Check manual override second
@@ -95,8 +97,5 @@ export const shouldShowSnow = (): boolean => {
return manualSnowOverride;
}
const today = new Date();
const month = today.getMonth();
const day = today.getDate();
return month === 11 && (day === 24 || day === 25);
return activeSpecialTheme === 'christmas';
};
+10 -1
View File
@@ -12,7 +12,7 @@ import {
} from '../replies-preview-utils';
import { getQuotedCidsFromContent, mergeQuotedCids } from '../reply-quote-utils';
import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../string-utils';
import { getFormattedDate, getFormattedTimeAgo, isChristmas } from '../time-utils';
import { getActiveSpecialTheme, getFormattedDate, getFormattedTimeAgo, getSpecialThemeClass, isChristmas, isHalloween } from '../time-utils';
const testState = vi.hoisted(() => ({
language: 'en',
@@ -276,8 +276,17 @@ describe('misc utils', () => {
vi.setSystemTime(new Date('2024-12-24T00:00:00Z'));
expect(isChristmas()).toBe(true);
expect(getActiveSpecialTheme()).toBe('christmas');
expect(getSpecialThemeClass('christmas')).toBe('tomorrow');
vi.setSystemTime(new Date('2024-10-31T00:00:00Z'));
expect(isHalloween()).toBe(true);
expect(getActiveSpecialTheme()).toBe('halloween');
expect(getSpecialThemeClass('halloween')).toBe('spooky');
vi.setSystemTime(new Date('2024-07-04T00:00:00Z'));
expect(isChristmas()).toBe(false);
expect(isHalloween()).toBe(false);
expect(getActiveSpecialTheme()).toBeNull();
});
});
+27
View File
@@ -92,3 +92,30 @@ export const isChristmas = (): boolean => {
const day = today.getDate();
return month === 11 && (day === 24 || day === 25);
};
export const isHalloween = (): boolean => {
const today = new Date();
const month = today.getMonth();
const day = today.getDate();
return month === 9 && day === 31;
};
export type ActiveSpecialTheme = 'christmas' | 'halloween';
export type SpecialThemeClass = 'tomorrow' | 'spooky';
export const getActiveSpecialTheme = (): ActiveSpecialTheme | null => {
if (isChristmas()) {
return 'christmas';
}
if (isHalloween()) {
return 'halloween';
}
return null;
};
export const getSpecialThemeClass = (specialTheme: ActiveSpecialTheme): SpecialThemeClass => {
if (specialTheme === 'halloween') {
return 'spooky';
}
return 'tomorrow';
};
@@ -104,7 +104,7 @@ describe('persisted extra stores', () => {
expect(useModQueueStore.getState().getAlertThresholdSeconds()).toBe(900);
});
it('blocks special theme enablement outside christmas and clears persisted enabled state on rehydrate', async () => {
it('blocks special theme enablement outside active special dates and clears persisted enabled state on rehydrate', async () => {
localStorage.setItem(
'Special-theme-storage',
JSON.stringify({
@@ -121,12 +121,20 @@ describe('persisted extra stores', () => {
expect(useSpecialThemeStore.getState().isEnabled).toBeNull();
});
it('allows opting into the special theme during christmas', async () => {
it('allows opting into the special theme during christmas and halloween', async () => {
const useSpecialThemeStore = await loadSpecialThemeStore('2024-12-24T00:00:00Z');
expect(useSpecialThemeStore.getState().isEnabled).toBeNull();
useSpecialThemeStore.getState().setIsEnabled(true);
expect(useSpecialThemeStore.getState().isEnabled).toBe(true);
localStorage.clear();
const useHalloweenSpecialThemeStore = await loadSpecialThemeStore('2024-10-31T00:00:00Z');
expect(useHalloweenSpecialThemeStore.getState().isEnabled).toBeNull();
useHalloweenSpecialThemeStore.getState().setIsEnabled(true);
expect(useHalloweenSpecialThemeStore.getState().isEnabled).toBe(true);
});
});
+3 -3
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { isChristmas } from '../lib/utils/time-utils';
import { getActiveSpecialTheme } from '../lib/utils/time-utils';
interface SpecialThemeStore {
isEnabled: boolean | null;
@@ -12,7 +12,7 @@ const useSpecialThemeStore = create(
(set) => ({
isEnabled: null,
setIsEnabled: (value: boolean) => {
if (value && !isChristmas()) {
if (value && !getActiveSpecialTheme()) {
return;
}
set({ isEnabled: value });
@@ -22,7 +22,7 @@ const useSpecialThemeStore = create(
name: 'Special-theme-storage',
onRehydrateStorage: () => {
return (state) => {
if (state && !isChristmas()) {
if (state && !getActiveSpecialTheme()) {
state.isEnabled = null;
}
};
+162 -1
View File
@@ -929,7 +929,8 @@
--mod-queue-alert-color: #d00;
}
:root .tomorrow {
:root .tomorrow,
:root .spooky {
--color-scheme: dark;
/* avatar */
@@ -1184,6 +1185,166 @@
--mod-queue-alert-color: #cc6666;
}
:root .spooky {
--body-background-base-color: #140814;
--body-font-color: #96b4aa;
--post-backlink-text-color: #d98518;
--post-backlink-text-color-hover: #ff9d1c;
--reply-backlink-mobile-background-color: #241023;
--reply-backlink-mobile-border-top: 1px solid #3a263c;
--board-title-text-color: #d98518;
--catalog-post-menu-desktop-btn-color: #d98518;
--catalog-post-menu-desktop-btn-hover-color: #ff9d1c;
--catalog-post-preview-background-color: #120c20;
--catalog-post-preview-color: #96b4aa;
--catalog-post-preview-subject-color: #b35968;
--catalog-post-preview-author-color: #d98518;
--catalog-post-preview-last-color: #96b4aa;
--challenge-modal-background-color: #241023;
--challenge-modal-border: 1px solid #3a263c;
--challenge-modal-title-background-color: #241023;
--challenge-modal-title-text-color: #d98518;
--button-desktop-text-color: #d98518;
--button-desktop-text-color-hover: #ff9d1c;
--pagelist-background-color: #241023;
--pagelist-item-background-color: #241023;
--pagelist-item-border-color: #3a263c;
--pagelist-item-text-color: #d98518;
--pagelist-item-text-color-hover: #ff9d1c;
--pagelist-item-text-color-current: #96b4aa;
--homepage-infobox-text-color: #96b4aa;
--homepage-infobox-bar-text-color: #d98518;
--homepage-infobox-bar-background-color: #241023;
--homepage-box-background-color: #1b1024;
--homepage-box-bar-background-color: #241023;
--homepage-box-border-color: #3a263c;
--homepage-box-bar-text-color: #d98518;
--homepage-box-bar-text-color-hover: #ff9d1c;
--directory-modal-background-color: #241023;
--directory-modal-border: 1px solid #3a263c;
--directory-modal-text-color: #96b4aa;
--directory-modal-header-background-color: #241023;
--directory-modal-header-text-color: #d98518;
--hr-border-top: 1px solid #3a263c;
--post-link-text-color: #d98518;
--post-link-text-color-hover: #ff9d1c;
--media-thumbnail-background-color: rgba(255, 157, 28, 0.05);
--button-text-color-mobile: #d98518;
--button-background-color-mobile: #241023;
--button-border-mobile: 1px solid #3a263c;
--post-capcode-mod-text-color: #d98518;
--post-subject-text-color: #b35968;
--post-name-text-color: #d98518;
--post-menu-desktop-button-color: #d98518;
--post-menu-desktop-button-color-hover: #ff9d1c;
--post-greentext-color: #789922;
--post-form-field-title-background-color: #241023;
--post-form-field-title-color: #d98518;
--post-form-field-border: 1px solid #3a263c;
--post-form-field-input-border: 1px solid #3a263c;
--post-form-field-input-focus-border: 1px solid #d98518;
--post-form-field-input-background-color: #120c20;
--post-form-field-input-color: #96b4aa;
--post-menu-desktop-border-bottom: 1px solid #3a263c;
--post-menu-desktop-background-color: #241023;
--post-menu-desktop-item-border-color: #3a263c;
--post-menu-desktop-item-background-color: #241023;
--post-menu-desktop-item-hover-background-color: #241023;
--post-menu-mobile-background-color: #241023;
--post-menu-mobile-border: 1px solid #3a263c;
--post-menu-mobile-item-border-bottom: 1px solid #3a263c;
--post-menu-mobile-item-hover-background-color: #241023;
--post-mobile-background-color: #1b1024;
--post-mobile-info-border-top: 1px solid #3a263c;
--post-mobile-info-border-bottom: 1px solid #3a263c;
--post-mobile-info-background-color: #241023;
--post-mobile-info-text-color: #96b4aa;
--post-mobile-menu-button-color: #d98518;
--post-mobile-name-text-color: #d98518;
--post-mobile-subject-text-color: #b35968;
--post-mobile-link-background-color: #241023;
--post-mobile-link-border-top: 1px solid #3a263c;
--post-mobile-link-border-bottom: 1px solid #3a263c;
--post-mobile-link-info-text-color: #96b4aa;
--post-mobile-abbr-text-color: #96b4aa;
--post-mobile-file-info-text-color: #96b4aa;
--post-quotelink-text-color: #d98518;
--post-quotelink-text-color-hover: #ff9d1c;
--quote-preview-background: #241023;
--quote-preview-border: 1px solid #3a263c;
--quote-preview-border-right: 1px solid #3a263c;
--quote-preview-border-bottom: 1px solid #3a263c;
--side-arrows-color: #96b4aa;
--reply-desktop-background-color: #241023;
--reply-desktop-border: 1px solid #3a263c;
--reply-desktop-border-left: 1px solid #3a263c;
--reply-desktop-border-top: 1px solid #3a263c;
--reply-modal-field-input-border: 1px solid #3a263c;
--reply-modal-field-border-focus: 1px solid #d98518;
--reply-modal-field-input-border-focus: 1px solid #d98518;
--reply-highlight-background-color: #2b1630;
--reply-highlight-border: 1px solid #6f3d22;
--reply-highlight-border-left: 1px solid #6f3d22;
--reply-highlight-border-top: 1px solid #6f3d22;
--reply-double-highlight-background-color: #33173b;
--reply-double-highlight-border: 1px solid #d98518;
--reply-double-highlight-border-left: 1px solid #d98518;
--reply-double-highlight-border-top: 1px solid #d98518;
--settings-modal-background-color: #241023;
--settings-modal-border-top: 1px solid #3a263c;
--settings-modal-border-right: 1px solid #3a263c;
--settings-modal-border-bottom: 1px solid #3a263c;
--settings-modal-border-left: 1px solid #3a263c;
--settings-modal-header-border-bottom: 1px solid #3a263c;
--tooltip-background-color: #120c20;
--boardsbar-separator-color: #96b4aa;
--boardsbar-desktop-link-text-color: #d98518;
--boardsbar-desktop-link-text-color-hover: #ff9d1c;
--boardsbar-mobile-background-color: #120c20;
--boardsbar-mobile-border-bottom: 2px solid #3a263c;
--boardsbar-mobile-button-text-color: #d98518;
--boardsbar-mobile-button-color: #d98518;
--mod-queue-table-border: 1px solid #3a263c;
--mod-queue-header-background-color: #241023;
--mod-queue-header-border-bottom: 1px solid #3a263c;
--mod-queue-row-background-color: #1b1024;
--mod-queue-row-border-bottom: 1px solid #3a263c;
--mod-queue-row-hover-background-color: #1b1024;
--mod-queue-button-text-color: #d98518;
--mod-queue-button-text-color-hover: #ff9d1c;
--mod-queue-approve-color: #d98518;
--mod-queue-approve-color-hover: #789922;
--mod-queue-reject-color: #d98518;
--mod-queue-reject-color-hover: #b35968;
--mod-queue-time-color: #96b4aa;
--mod-queue-alert-color: #b35968;
}
:root .photon {
--color-scheme: light;
+11
View File
@@ -212,6 +212,10 @@
background: rgba(255, 255, 255, 0.1);
}
:global(body.spooky) .rowOdd td {
background: #1b1024;
}
:global(body.tomorrow) .flashListing thead th,
:global(body.tomorrow) .flashListing thead td {
background: var(--post-form-field-title-background-color);
@@ -219,6 +223,13 @@
border: none;
}
:global(body.spooky) .flashListing thead th,
:global(body.spooky) .flashListing thead td {
background: var(--post-form-field-title-background-color);
color: var(--post-form-field-title-color);
border: none;
}
:global(body.photon) .rowOdd td {
background: #888;
}
+11
View File
@@ -307,6 +307,10 @@
background: rgba(255, 255, 255, 0.1);
}
:global(body.spooky) .rowOdd td {
background: #1b1024;
}
:global(body.tomorrow) .flashListing thead th,
:global(body.tomorrow) .flashListing thead td {
background: var(--post-form-field-title-background-color);
@@ -314,6 +318,13 @@
border: none;
}
:global(body.spooky) .flashListing thead th,
:global(body.spooky) .flashListing thead td {
background: var(--post-form-field-title-background-color);
color: var(--post-form-field-title-color);
border: none;
}
:global(body.photon) .rowOdd td {
background: #888;
}
+17
View File
@@ -204,6 +204,16 @@
background-color: rgba(255, 255, 255, 0.1);
}
:global(body.spooky) .rowOdd .number,
:global(body.spooky) .rowOdd .board,
:global(body.spooky) .rowOdd .excerpt,
:global(body.spooky) .rowOdd .time,
:global(body.spooky) .rowOdd .type,
:global(body.spooky) .rowOdd .image,
:global(body.spooky) .rowOdd .actions {
background-color: #1b1024;
}
:global(body.photon) .rowOdd .number,
:global(body.photon) .rowOdd .board,
:global(body.photon) .rowOdd .excerpt,
@@ -609,6 +619,13 @@ span.reject:hover {
:global(body.tomorrow) .rowOdd .type,
:global(body.tomorrow) .rowOdd .image,
:global(body.tomorrow) .rowOdd .actions,
:global(body.spooky) .rowOdd .number,
:global(body.spooky) .rowOdd .board,
:global(body.spooky) .rowOdd .excerpt,
:global(body.spooky) .rowOdd .time,
:global(body.spooky) .rowOdd .type,
:global(body.spooky) .rowOdd .image,
:global(body.spooky) .rowOdd .actions,
:global(body.photon) .rowOdd .number,
:global(body.photon) .rowOdd .board,
:global(body.photon) .rowOdd .excerpt,