feat(appearance): Halloween special theme with 3D decor and a costumed mascot

Adds a Special Themes row to Settings > Appearance > Theme. Halloween is a
scheme on the existing data-scheme axis that is always dark: the applier and
the boot script force .dark while it is on, and the color mode control is
dimmed with a hint.

- Palette for the whole app (surfaces, dashboard, Vacay glass, Journey,
  mobile chrome) in theme/special/halloween.tokens.css, loaded with index.css.
- Decor as a lazy chunk mounted by SpecialThemeLayer while the scheme is on:
  fog, embers, bat flocks, a passing ghost, corner cobwebs, spiders that drop
  on a thread and flee when poked, a pumpkin patch and an ember running along
  the navbar. Figures are pre-rendered 3D sprite sheets.
- Nothing is drawn over a map: the Atlas gets no decor, the trip planner only
  dresses its side panels. Marker clusters turn into lanterns.
- The 3D mascot plays its own Halloween clips in every scene (witch hat, a
  spider on the brim, a lit pumpkin beside it); the SVG fallback wears the
  same costume.
- Reduced motion stills everything, transparency off drops the fog, shared
  pages never show it.
- Navbar, bottom nav, notifications, Atlas and Collections read the dark flag
  off the .dark class instead of settings.dark_mode, so a forced dark theme
  reaches them.
This commit is contained in:
Maurice
2026-10-18 12:59:36 +02:00
parent bda24ec639
commit ce1fa68c84
113 changed files with 2973 additions and 156 deletions
+1
View File
@@ -47,6 +47,7 @@
},
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/creepster": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/inter": "^5.3.0",
+2
View File
@@ -24,6 +24,8 @@
if (isShared) dark = false;
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
else dark = s.darkMode === true || s.darkMode === 'dark';
// Special themes (APPEARANCE_SPECIAL_SCHEMES in @trek/shared) are dark-only.
if (!isShared && s.scheme === 'halloween') dark = true;
root.classList.toggle('dark', dark);
var scheme = isShared ? 'default' : s.scheme;
+2
View File
@@ -33,6 +33,7 @@ import OfflineBanner from './components/Layout/OfflineBanner'
import NewVersionNotice from './components/Layout/NewVersionNotice'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
import SpecialThemeLayer from './theme/special/SpecialThemeLayer'
// Notice action registrations (side-effect imports):
import './pages/Trips/noticeActions.js'
import { managedRoutes } from './managed'
@@ -405,6 +406,7 @@ export default function App() {
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:special-theme" fallback={null}><SpecialThemeLayer /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing
+3 -2
View File
@@ -9,6 +9,7 @@ import { resolvePluginIcon } from '../shared/PluginIcon'
import { useAuthStore } from '../../store/authStore'
import { visibleManagedNavItems } from '../../managed'
import { isNavItemActive, useCreateAction } from './useCreateAction'
import { useIsDark } from '../../hooks/useIsDark'
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
@@ -22,8 +23,8 @@ interface NavItem { to: string; label: string; icon: LucideIcon }
export default function BottomNav() {
const { t } = useTranslation()
const navigate = useNavigate()
const darkMode = useSettingsStore(s => s.settings.dark_mode)
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = useIsDark()
const addons = useAddonStore(s => s.addons)
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
// Page plugins are reachable from the mobile tab bar too, mirroring the desktop
@@ -7,13 +7,14 @@ import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
import { useIsDark } from '../../hooks/useIsDark'
export default function InAppNotificationBell(): React.ReactElement {
const { t } = useTranslation()
const navigate = useNavigate()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = useIsDark()
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
+4 -3
View File
@@ -13,6 +13,7 @@ import HelpButton from '../Help/HelpButton'
import { resolvePluginIcon } from '../shared/PluginIcon'
import { visibleManagedNavItems } from '../../managed'
import { useCollapseOnOverflow } from '../../hooks/useCollapseOnOverflow'
import { useIsDark } from '../../hooks/useIsDark'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
@@ -41,8 +42,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [scrolled, setScrolled] = useState<boolean>(false)
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = useIsDark()
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8)
@@ -121,7 +122,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
}} className="trek-navbar hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
the tab pill between them sits in the middle of the bar rather than
being centred on top of both (#1983). Both columns keep at least their
@@ -4,6 +4,7 @@ import { useTranslation } from '../../i18n'
import type { InAppNotification } from '../../store/inAppNotificationStore'
import { useSettingsStore } from '../../store/settingsStore'
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
import { useIsDark } from '../../hooks/useIsDark'
function relativeTime(dateStr: string, locale: string): string {
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
@@ -17,8 +18,8 @@ interface NotificationItemProps {
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
const { t, locale } = useTranslation()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = useIsDark()
const { responding, handleRespond, handleNavigate, markRead, deleteNotification } =
useNotificationItemActions(notification, onClose)
@@ -6,7 +6,8 @@ import { Segmented } from '../shared/dialogParts'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit'
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS } from '../../theme/schemes'
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS, SPECIAL_THEMES } from '../../theme/schemes'
import SpecialThemeCard from '../../theme/special/SpecialThemeCard'
import { APPEARANCE_SCALE_MIN, APPEARANCE_SCALE_MAX } from '@trek/shared'
import { DESKTOP_GROUPS, MOBILE_GROUPS, isHex } from './appearanceModel'
import { useAppearanceEditor } from './useAppearanceEditor'
@@ -35,7 +36,7 @@ const DOT = 'h-4 w-4 flex-none rounded-full shadow-[inset_0_0_0_1px_var(--border
export default function AppearanceSettingsTab(): React.ReactElement {
const { t } = useTranslation()
const tr = (key: string, fallback: string) => t(key) || fallback
const { cfg, darkMode, isDark, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
const { cfg, darkMode, isDark, modeLocked, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
useAppearanceEditor()
// The stored mode, read the way the old boolean values meant it.
@@ -50,7 +51,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
{/* Color mode */}
<SettingRow
label={tr('settings.colorMode', 'Color mode')}
hint={modeLocked ? t('settings.appearance.special.modeLocked') : undefined}
control={
<div className={modeLocked ? 'pointer-events-none opacity-45' : undefined} aria-disabled={modeLocked || undefined}>
<Segmented
label={tr('settings.colorMode', 'Color mode')}
value={mode}
@@ -70,9 +73,20 @@ export default function AppearanceSettingsTab(): React.ReactElement {
},
]}
/>
</div>
}
/>
{/* Special themes: a whole look, not just an accent */}
<SettingRow label={t('settings.appearance.special')} stacked>
<div className="grid gap-2 sm:grid-cols-2">
{SPECIAL_THEMES.map((s) => (
<SpecialThemeCard key={s.id} theme={s} active={cfg.schemeId === s.id}
onSelect={() => update({ schemeId: s.id })} />
))}
</div>
</SettingRow>
{/* Color scheme swatches */}
<SettingRow label={tr('settings.appearance.scheme', 'Color scheme')} stacked>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
@@ -290,6 +304,7 @@ function schemeFallback(id: string): string {
rose: 'Rose',
amber: 'Amber',
violet: 'Violet',
halloween: 'Halloween',
}
return map[id] || id
}
@@ -1,6 +1,7 @@
import { DEFAULT_APPEARANCE, normalizeAppearance, type AppearanceConfig } from '@trek/shared';
import { DEFAULT_APPEARANCE, isSpecialScheme, normalizeAppearance, type AppearanceConfig } from '@trek/shared';
import { useEffect, useRef, useState } from 'react';
import { useIsDark } from '../../hooks/useIsDark';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { applyAppearance } from '../../theme/applyAppearance';
@@ -42,10 +43,10 @@ export function useAppearanceEditor() {
[updateSetting]
);
const isDark =
settings.dark_mode === true ||
settings.dark_mode === 'dark' ||
(settings.dark_mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
// Off the `.dark` class: a special theme forces dark whatever the stored mode says.
const isDark = useIsDark();
// While a special theme is on, the color mode has nothing to switch.
const modeLocked = isSpecialScheme(cfg.schemeId);
// Live preview now (DOM), persist after a short debounce (API).
const update = (patch: Partial<AppearanceConfig>) => {
@@ -83,6 +84,7 @@ export function useAppearanceEditor() {
cfg,
darkMode: settings.dark_mode,
isDark,
modeLocked,
update,
setMode,
setWidget,
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react';
const read = (): string =>
(typeof document !== 'undefined' && document.documentElement.getAttribute('data-scheme')) || 'default';
/**
* The scheme currently on `<html data-scheme>`, as `applyAppearance()` and the
* pre-paint boot script wrote it. Same contract as `useIsDark`: components read
* the DOM, never the appearance config, so shared pages (which drop the
* attribute) and the live preview in settings are covered for free.
*
* Only for decor that has to exist in the DOM, like a special theme's overlay
* or the mascot's hat. Colours belong on tokens.
*/
export function useActiveScheme(): string {
const [scheme, setScheme] = useState<string>(read);
useEffect(() => {
if (typeof document === 'undefined') return;
const mo = new MutationObserver(() => setScheme(read()));
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-scheme'] });
return () => mo.disconnect();
}, []);
return scheme;
}
+3
View File
@@ -25,6 +25,9 @@ import '@fontsource/geist-sans/600.css'
// several .leaflet-* styles.
import 'leaflet/dist/leaflet.css'
import './index.css'
// Special themes: the palette is eager (first paint), the decor loads with its layer.
import '@fontsource/creepster/400.css'
import './theme/special/halloween.tokens.css'
import './styles/ferratas.css'
import './styles/paddle.css'
// Native HTML5 drag-and-drop never fires on touch input, so the planner's place /
+16 -3
View File
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { useActiveScheme } from '../../hooks/useActiveScheme';
import { MascotHat, MascotPumpkin } from '../../theme/special/halloween/MascotCostume';
import MascotClip from './trek3d/MascotClip';
import { findClip, mascot3dEnabled } from './trek3d/mascotClips';
@@ -66,6 +68,8 @@ type MascotProps = {
export default function MDancingTrek(props: MascotProps) {
const { size = 96, scene = 'idle', mood, className = '' } = props;
const sceneMood = SCENE_MOOD[scene] ?? 'default';
// Special themes dress the mascot: the 3D clips come in costume, and so does the SVG.
const costume = useActiveScheme() === 'halloween';
const clip =
import.meta.env.MODE !== 'test' && mascot3dEnabled() ? findClip(scene, mood ?? sceneMood, sceneMood) : null;
if (clip)
@@ -75,13 +79,20 @@ export default function MDancingTrek(props: MascotProps) {
scene={scene}
size={size}
className={className}
fallback={<MDancingTrekSvg {...props} />}
costume={costume}
fallback={<MDancingTrekSvg {...props} costume={costume} />}
/>
);
return <MDancingTrekSvg {...props} />;
return <MDancingTrekSvg {...props} costume={costume} />;
}
function MDancingTrekSvg({ size = 96, scene = 'idle', mood, className = '' }: MascotProps) {
function MDancingTrekSvg({
size = 96,
scene = 'idle',
mood,
className = '',
costume = false,
}: MascotProps & { costume?: boolean }) {
const face = mood ?? SCENE_MOOD[scene] ?? 'default';
// Ground shadow sits under whatever the mascot stands on this scene.
const shadowCy = scene === 'transport' ? 86 : 73;
@@ -111,8 +122,10 @@ function MDancingTrekSvg({ size = 96, scene = 'idle', mood, className = '' }: Ma
</g>
<Eyes mood={face} />
<SceneOnBody scene={scene} />
{costume && <MascotHat />}
</g>
</g>
{costume && <MascotPumpkin groundY={shadowCy} />}
{/* scene prop in front of the body — animates on its own */}
<SceneFront scene={scene} />
@@ -14,7 +14,8 @@ import { CLIPS } from './mascotClips';
* stay 2 px stand-ins: browsers hang their own controls on visible videos.
*
* Clips live next to this file as `<scene>[--<mood>]-<light|dark>-<loop|poke>.mp4`,
* cropped to the area the scene uses; `crop` in clips.meta.json places them back
* plus a `halloween` set in costume (hat, pumpkin) whose crops sit under
* `<clip>@halloween`. They are cropped to the area the scene uses; `crop` in clips.meta.json places them back
* inside the full 88:96 box so every scene keeps the same scale. Only URLs are
* bundled; the files load when a mascot actually renders.
*/
@@ -116,6 +117,7 @@ export default function MascotClip({
size,
className = '',
fallback,
costume = false,
}: {
clip: string;
scene: string;
@@ -123,12 +125,17 @@ export default function MascotClip({
className?: string;
/** shown when the device refuses to autoplay (e.g. iOS low power mode) */
fallback: ReactNode;
/** The Halloween theme is on: play the clips in costume where they exist. */
costume?: boolean;
}) {
const theme = useIsDark() ? 'dark' : 'light';
const dark = useIsDark();
const dressed = costume && !!CLIPS[`./clips/${clip}-halloween-loop.mp4`];
const theme = dressed ? 'halloween' : dark ? 'dark' : 'light';
const loopUrl = CLIPS[`./clips/${clip}-${theme}-loop.mp4`];
const pokeUrl = CLIPS[`./clips/${scene}-${theme}-poke.mp4`];
const pokeMs = (META[scene]?.poke ?? 2) * 1000;
const [x, y, w, h] = META[clip]?.crop ?? [0, 0, 1, 1];
const metaKey = (key: string) => (dressed ? `${key}@halloween` : key);
const pokeMs = (META[metaKey(scene)]?.poke ?? 2) * 1000;
const [x, y, w, h] = META[metaKey(clip)]?.crop ?? [0, 0, 1, 1];
const boxW = size * CLIP_SCALE,
boxH = (boxW * 96) / 88;
@@ -1,122 +1,542 @@
{
"idle": {
"poke": 2.3,
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
},
"idle--sleepy": {
"poke": 2.3,
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
},
"idle--happy": {
"poke": 2.3,
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
},
"idle--sad": {
"poke": 2.3,
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
},
"idle--confused": {
"poke": 2.3,
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
},
"idle--error": {
"poke": 2.3,
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
},
"dashboard": {
"poke": 2,
"crop": [0.07273, 0.14583, 0.88182, 0.625]
},
"dashboard--happy": {
"poke": 2,
"crop": [0.07273, 0.14583, 0.88182, 0.625]
},
"transport": {
"poke": 1.5,
"crop": [0.19545, 0.0, 0.64545, 0.75]
},
"transport--sad": {
"poke": 1.5,
"crop": [0.19545, 0.0, 0.64545, 0.75]
},
"transport--happy": {
"poke": 1.5,
"crop": [0.19545, 0.0, 0.64545, 0.75]
},
"bookings": {
"poke": 2.1,
"crop": [0.16364, 0.19167, 0.83636, 0.575]
},
"tours": {
"poke": 2.2,
"crop": [0.08636, 0.11667, 0.81364, 0.65417]
},
"packing": {
"poke": 2.45,
"crop": [0.09091, 0.025, 0.82273, 0.7125]
},
"packing--happy": {
"poke": 2.45,
"crop": [0.09091, 0.025, 0.82273, 0.7125]
},
"tasks": {
"poke": 2,
"crop": [0.13182, 0.1125, 0.85909, 0.80833]
},
"costs": {
"poke": 2.3,
"crop": [0.14091, 0.0, 0.85909, 0.7625]
},
"files": {
"poke": 2.1,
"crop": [0.16364, 0.21667, 0.76818, 0.52917]
},
"notes": {
"poke": 2.6,
"crop": [0.13182, 0.0875, 0.86818, 0.67083]
},
"links": {
"poke": 2.2,
"crop": [0.03636, 0.08333, 0.91364, 0.66667]
},
"polls": {
"poke": 2,
"crop": [0.08636, 0.12917, 0.76364, 0.6125]
},
"chat": {
"poke": 2.2,
"crop": [0.12727, 0.18333, 0.80455, 0.55417]
},
"guide": {
"poke": 2.2,
"crop": [0.10909, 0.1875, 0.85909, 0.55]
},
"journey": {
"poke": 2.2,
"crop": [0.09545, 0.2, 0.86818, 0.57083]
},
"collections": {
"poke": 2.25,
"crop": [0.0, 0.0, 0.95455, 0.75417]
},
"collections--happy": {
"poke": 2.25,
"crop": [0.0, 0.0, 0.95455, 0.75417]
},
"atlas": {
"poke": 2.4,
"crop": [0.12273, 0.20833, 0.70909, 0.52917]
},
"notifications": {
"poke": 2.4,
"crop": [0.00909, 0.05417, 0.86364, 0.6875]
},
"search": {
"poke": 2.2,
"crop": [0.14545, 0.0125, 0.75909, 0.75833]
},
"search--sad": {
"poke": 2.2,
"crop": [0.14545, 0.0125, 0.75909, 0.75833]
}
}
"idle": {
"poke": 2.3,
"crop": [
0.14545,
0.11667,
0.72273,
0.74167
]
},
"idle--sleepy": {
"poke": 2.3,
"crop": [
0.14545,
0.11667,
0.72273,
0.74167
]
},
"idle--happy": {
"poke": 2.3,
"crop": [
0.14545,
0.11667,
0.72273,
0.74167
]
},
"idle--sad": {
"poke": 2.3,
"crop": [
0.14545,
0.11667,
0.72273,
0.74167
]
},
"idle--confused": {
"poke": 2.3,
"crop": [
0.14545,
0.11667,
0.72273,
0.74167
]
},
"idle--error": {
"poke": 2.3,
"crop": [
0.14545,
0.11667,
0.72273,
0.74167
]
},
"dashboard": {
"poke": 2,
"crop": [
0.07273,
0.14583,
0.88182,
0.625
]
},
"dashboard--happy": {
"poke": 2,
"crop": [
0.07273,
0.14583,
0.88182,
0.625
]
},
"transport": {
"poke": 1.5,
"crop": [
0.19545,
0.0,
0.64545,
0.75
]
},
"transport--sad": {
"poke": 1.5,
"crop": [
0.19545,
0.0,
0.64545,
0.75
]
},
"transport--happy": {
"poke": 1.5,
"crop": [
0.19545,
0.0,
0.64545,
0.75
]
},
"bookings": {
"poke": 2.1,
"crop": [
0.16364,
0.19167,
0.83636,
0.575
]
},
"tours": {
"poke": 2.2,
"crop": [
0.08636,
0.11667,
0.81364,
0.65417
]
},
"packing": {
"poke": 2.45,
"crop": [
0.09091,
0.025,
0.82273,
0.7125
]
},
"packing--happy": {
"poke": 2.45,
"crop": [
0.09091,
0.025,
0.82273,
0.7125
]
},
"tasks": {
"poke": 2,
"crop": [
0.13182,
0.1125,
0.85909,
0.80833
]
},
"costs": {
"poke": 2.3,
"crop": [
0.14091,
0.0,
0.85909,
0.7625
]
},
"files": {
"poke": 2.1,
"crop": [
0.16364,
0.21667,
0.76818,
0.52917
]
},
"notes": {
"poke": 2.6,
"crop": [
0.13182,
0.0875,
0.86818,
0.67083
]
},
"links": {
"poke": 2.2,
"crop": [
0.03636,
0.08333,
0.91364,
0.66667
]
},
"polls": {
"poke": 2,
"crop": [
0.08636,
0.12917,
0.76364,
0.6125
]
},
"chat": {
"poke": 2.2,
"crop": [
0.12727,
0.18333,
0.80455,
0.55417
]
},
"guide": {
"poke": 2.2,
"crop": [
0.10909,
0.1875,
0.85909,
0.55
]
},
"journey": {
"poke": 2.2,
"crop": [
0.09545,
0.2,
0.86818,
0.57083
]
},
"collections": {
"poke": 2.25,
"crop": [
0.0,
0.0,
0.95455,
0.75417
]
},
"collections--happy": {
"poke": 2.25,
"crop": [
0.0,
0.0,
0.95455,
0.75417
]
},
"atlas": {
"poke": 2.4,
"crop": [
0.12273,
0.20833,
0.70909,
0.52917
]
},
"notifications": {
"poke": 2.4,
"crop": [
0.00909,
0.05417,
0.86364,
0.6875
]
},
"search": {
"poke": 2.2,
"crop": [
0.14545,
0.0125,
0.75909,
0.75833
]
},
"search--sad": {
"poke": 2.2,
"crop": [
0.14545,
0.0125,
0.75909,
0.75833
]
},
"costs@halloween": {
"poke": 2.3,
"crop": [
0.08182,
-0.02917,
0.92273,
0.79583
]
},
"idle@halloween": {
"poke": 2.3,
"crop": [
0.11364,
-0.07083,
0.75455,
0.92917
]
},
"idle--happy@halloween": {
"poke": 2.3,
"crop": [
0.11364,
-0.07083,
0.75455,
0.92917
]
},
"idle--sad@halloween": {
"poke": 2.3,
"crop": [
0.11364,
-0.07083,
0.75455,
0.92917
]
},
"idle--sleepy@halloween": {
"poke": 2.3,
"crop": [
0.11364,
-0.07083,
0.75455,
0.92917
]
},
"idle--confused@halloween": {
"poke": 2.3,
"crop": [
0.11364,
-0.07083,
0.75455,
0.92917
]
},
"idle--error@halloween": {
"poke": 2.3,
"crop": [
0.11364,
-0.07083,
0.75455,
0.92917
]
},
"dashboard@halloween": {
"poke": 2,
"crop": [
0.07273,
-0.04167,
0.88182,
0.8125
]
},
"dashboard--happy@halloween": {
"poke": 2,
"crop": [
0.07273,
-0.04167,
0.88182,
0.8125
]
},
"transport@halloween": {
"poke": 1.5,
"crop": [
0.11364,
-0.17917,
0.72727,
0.94583
]
},
"transport--happy@halloween": {
"poke": 1.5,
"crop": [
0.11364,
-0.17917,
0.72727,
0.94583
]
},
"transport--sad@halloween": {
"poke": 1.5,
"crop": [
0.11364,
-0.17917,
0.72727,
0.94583
]
},
"bookings@halloween": {
"poke": 2.1,
"crop": [
0.09091,
0.0125,
0.96818,
0.75417
]
},
"tours@halloween": {
"poke": 2.2,
"crop": [
0.08636,
-0.06667,
0.81364,
0.8375
]
},
"packing@halloween": {
"poke": 2.45,
"crop": [
-0.06364,
-0.12083,
0.97727,
0.8875
]
},
"packing--happy@halloween": {
"poke": 2.45,
"crop": [
-0.06364,
-0.12083,
0.97727,
0.8875
]
},
"tasks@halloween": {
"poke": 2,
"crop": [
0.07727,
-0.05833,
0.91364,
0.97917
]
},
"files@halloween": {
"poke": 2.1,
"crop": [
0.09091,
0.04583,
0.84091,
0.72083
]
},
"notes@halloween": {
"poke": 2.6,
"crop": [
0.07727,
0.04583,
0.95,
0.72083
]
},
"links@halloween": {
"poke": 2.2,
"crop": [
0.03636,
0.04583,
0.91364,
0.725
]
},
"polls@halloween": {
"poke": 2,
"crop": [
0.05909,
-0.0625,
0.79091,
0.83333
]
},
"chat@halloween": {
"poke": 2.2,
"crop": [
0.07727,
0.00417,
0.85455,
0.7625
]
},
"guide@halloween": {
"poke": 2.2,
"crop": [
0.06818,
0.02083,
0.9,
0.75
]
},
"journey@halloween": {
"poke": 2.2,
"crop": [
0.06364,
0.03333,
0.9,
0.7375
]
},
"collections@halloween": {
"poke": 2.25,
"crop": [
-0.04545,
-0.29167,
1.0,
1.0625
]
},
"collections--happy@halloween": {
"poke": 2.25,
"crop": [
-0.04545,
-0.29167,
1.0,
1.0625
]
},
"atlas@halloween": {
"poke": 2.4,
"crop": [
0.07273,
0.03333,
0.75909,
0.73333
]
},
"notifications@halloween": {
"poke": 2.4,
"crop": [
0.00909,
-0.05417,
0.86364,
0.825
]
},
"search@halloween": {
"poke": 2.2,
"crop": [
0.08182,
0.0125,
0.82273,
0.75833
]
},
"search--sad@halloween": {
"poke": 2.2,
"crop": [
0.08182,
0.0125,
0.82273,
0.75833
]
}
}
+3 -3
View File
@@ -295,7 +295,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
<PanelTab hidden={leftHidden} label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} onToggle={toggleLeft} />
<div style={{
<div className="trek-planner-panel" style={{
width: leftHidden ? 0 : leftWidth, height: '100%',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(24px) saturate(180%)',
@@ -420,7 +420,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
<PanelTab side="right" hidden={rightHidden} label={rightHidden ? t('trip.mobilePlaces') : t('common.collapse')} onToggle={toggleRight} />
<div style={{
<div className="trek-planner-panel" style={{
width: rightHidden ? 0 : rightWidth, height: '100%',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(24px) saturate(180%)',
@@ -638,7 +638,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
/>
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
<PanelTab hidden={leftHidden} label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} onToggle={toggleLeft} />
<div style={{
<div className="trek-planner-panel" style={{
width: leftHidden ? 0 : leftPanelPx, height: '100%', position: 'relative',
background: 'var(--sidebar-bg)', backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)', boxShadow: leftHidden ? 'none' : 'var(--sidebar-shadow)',
+3 -2
View File
@@ -17,6 +17,7 @@ import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
import type { AtlasConfirmAction, VisitedRegionMap } from './useAtlasCountryActions'
import { useIsDark } from '../../hooks/useIsDark'
const PLANNED_KEY = 'trek_atlas_show_planned'
@@ -126,8 +127,8 @@ export function useAtlas() {
const navigate = useNavigate()
const toast = useToast()
const resolveName = useCountryNames(language)
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = useIsDark()
// Label-free tiles on purpose (the country fills carry the names here), so the
// user's own template is deliberately not read, only their CARTO key.
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON, true)
@@ -17,6 +17,7 @@ import type { Collection, CollectionStatus, CollectionFile } from '@trek/shared'
import type { Category, Place } from '../../types'
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories, presentLabels } from './collectionsModel'
import type { CollectionLabelUpdateRequest } from '@trek/shared'
import { useIsDark } from '../../hooks/useIsDark'
/** Quiet time after the last collections:* event before the page reloads. */
const WS_RELOAD_SETTLE_MS = 400
@@ -34,8 +35,9 @@ export function useCollections() {
const { id: routeId } = useParams<{ id: string }>()
const toast = useToast()
const dm = useSettingsStore(s => s.settings.dark_mode)
const dark = dm === true || dm === 'dark' || (dm === 'auto' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = useIsDark()
// Desktop breakpoint for the list+map split (list view only). Below it the
// list stays single-column and the map is its own view.
+20
View File
@@ -19,6 +19,24 @@ free **if they follow the contract below**.
- Schemes are data: swatch metadata in `src/theme/schemes.ts`, token values in
the `[data-scheme="…"]` blocks in `src/index.css`.
## Special themes
A special theme (today `halloween`) is a scheme on the same `data-scheme` axis,
so it needs no applier of its own. Three things set it apart, all keyed to
`APPEARANCE_SPECIAL_SCHEMES` in `@trek/shared`:
- **Always dark.** `applyAppearance()` and the boot script force `.dark` while
one is on; the color-mode control in settings is dimmed with a hint.
- **Palette** in `src/theme/special/<id>.tokens.css`, loaded eagerly next to
`index.css` so first paint is right. It overrides surfaces, text and the
feature palettes (`.trek-dash`, `--vg-*`, `--journal-*`, mobile `--m-*`).
- **Decor** in `src/theme/special/<id>/`, a lazy chunk that `SpecialThemeLayer`
mounts while the scheme is on `<html>` (read through `useActiveScheme`, never
from the config). It respects reduced motion and transparency off, and never
draws over a map: the Atlas gets none, the trip planner dresses only its side
panels. Figures are pre-rendered 3D sprite sheets; the mascot plays its own
`-<id>-` clips.
## Token taxonomy
| Purpose | Token / utility |
@@ -40,7 +58,9 @@ Fixed and sticky surfaces read their stacking order from one scale in
| Token | Value | For |
| --- | --- | --- |
| `--z-bar` | 60 | `BottomNav`, page-bound sticky bars |
| `--z-decor-ambient` | 190 | a special theme's sky, over the content and under the navbar (only while one is on) |
| `--z-nav` | 200 | the desktop navbar |
| `--z-decor` | 9000 | a special theme's webs and spiders, over the navbar and under its menus (only while one is on) |
| `--z-modal` | 10000 | `shared/Modal`, `ConfirmDialog` |
| `--z-overlay` | 99990 | page-owned full-screen overlays |
| `--z-notice` | 99998 | app-wide system and release notices |
+5 -4
View File
@@ -1,4 +1,4 @@
import { normalizeAppearance, type AppearanceConfig } from '@trek/shared'
import { isSpecialScheme, normalizeAppearance, type AppearanceConfig } from '@trek/shared'
/**
* The ONE place that writes appearance state to the DOM.
@@ -97,7 +97,8 @@ export function applyAppearance(input: ApplyAppearanceInput): AppearanceConfig {
? { ...cfg, schemeId: 'default', accent: null, transparency: true }
: cfg
const dark = resolveDark(input.darkMode, isShared)
// Special themes ship only a dark variant, whatever the color mode says.
const dark = isSpecialScheme(eff.schemeId) || resolveDark(input.darkMode, isShared)
const root = document.documentElement
root.classList.toggle('dark', dark)
@@ -147,9 +148,9 @@ export function applyAppearance(input: ApplyAppearanceInput): AppearanceConfig {
if (eff.fontScale === 1) root.style.removeProperty('font-size')
else root.style.fontSize = `${eff.fontScale * 100}%`
// theme-color meta — unchanged from before (dark #09090b / light #ffffff).
// theme-color meta — dark #09090b / light #ffffff, special themes their own night.
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', dark ? '#09090b' : '#ffffff')
if (meta) meta.setAttribute('content', eff.schemeId === 'halloween' ? '#0c0812' : dark ? '#09090b' : '#ffffff')
writeSnapshot({
v: 1,
+19 -1
View File
@@ -7,11 +7,29 @@ import type { AppearanceSchemeId } from '@trek/shared'
* dot. Labels come from i18n (settings.appearance.scheme.<id>).
*/
export interface SchemeSwatch {
id: Exclude<AppearanceSchemeId, 'custom'>
id: Exclude<AppearanceSchemeId, 'custom' | 'halloween'>
/** Representative accent color shown in the picker, per color mode. */
swatch: { light: string; dark: string }
}
/**
* Special themes get their own row in the picker with a larger preview card.
* Their palette lives in index.css like every scheme; the decor (cobwebs, bats,
* the mascot's hat) hangs off `html[data-scheme="<id>"]` in theme/special/.
*/
export interface SpecialTheme {
id: 'halloween'
/** Background of the preview card in the picker. */
preview: string
}
export const SPECIAL_THEMES: SpecialTheme[] = [
{
id: 'halloween',
preview: 'radial-gradient(circle at 78% 26%, #fde8c8 0 7%, #f9a14a 8%, transparent 22%), linear-gradient(160deg, #2a1640 0%, #120a1c 55%, #07040b 100%)',
},
]
export const APPEARANCE_SCHEMES: SchemeSwatch[] = [
{ id: 'default', swatch: { light: '#111827', dark: '#e4e4e7' } },
{ id: 'highContrast', swatch: { light: '#1d4ed8', dark: '#60a5fa' } },
@@ -0,0 +1,63 @@
import { Sparkles } from 'lucide-react';
import { useTranslation } from '../../i18n';
import type { SpecialTheme } from '../schemes';
import { Cobweb } from './halloween/art';
import Sprite from './halloween/Sprite';
import { SPRITES } from './halloween/sprites';
import './specialThemeCard.css';
/**
* The picker tile for a special theme, shared by the desktop tab and the phone
* screen. Larger than a scheme swatch on purpose: it previews the theme itself,
* a moonlit night with webs, bats and a lantern, and wakes up on hover.
*
* The --hw-* tokens it paints with are only defined while the theme is on, so
* the card sets them on itself (specialThemeCard.css) and looks the same before
* and after it is chosen.
*/
export default function SpecialThemeCard({
theme,
active,
onSelect,
}: {
theme: SpecialTheme;
active: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
return (
<button
type="button"
aria-pressed={active}
onClick={onSelect}
className={`stc stc--${theme.id} ${active ? 'stc--active' : ''}`}
>
<span className="stc-border" aria-hidden="true" />
<span className="stc-sky" aria-hidden="true">
<span className="stc-aurora" />
<span className="stc-moon" />
<span className="stc-fog" />
<Cobweb size={58} seed={7} className="stc-web stc-web--l" />
<Cobweb size={44} seed={19} className="stc-web stc-web--r" />
<span className="stc-bats">
<Sprite sheet={SPRITES.bat} height={17} offset={0} className="stc-bat stc-bat--1" />
<Sprite sheet={SPRITES.bat} height={12} offset={0.4} className="stc-bat stc-bat--2" />
<Sprite sheet={SPRITES.bat} height={14} offset={0.7} className="stc-bat stc-bat--3" />
</span>
<Sprite sheet={SPRITES.ghost} height={44} className="stc-ghost" />
<span className="stc-pumpkins">
<Sprite sheet={SPRITES.pumpkin} height={34} className="stc-pumpkin stc-pumpkin--1" />
<Sprite sheet={SPRITES.pumpkin} height={24} offset={0.5} className="stc-pumpkin stc-pumpkin--2" />
</span>
</span>
<span className="stc-copy">
<span className="stc-title">{t(`settings.appearance.scheme.${theme.id}`)}</span>
<span className="stc-hint">{t(`settings.appearance.special.${theme.id}.hint`)}</span>
</span>
<span className={`stc-badge ${active ? 'stc-badge--on' : ''}`}>
<Sparkles size={12} strokeWidth={2.2} />
{active ? t('settings.appearance.special.active') : t('settings.appearance.special.try')}
</span>
</button>
);
}
@@ -0,0 +1,37 @@
import { lazy, Suspense, useCallback, useState } from 'react';
import { useLocation } from 'react-router';
import { useActiveScheme } from '../../hooks/useActiveScheme';
const HalloweenLayer = lazy(() => import('./halloween/HalloweenLayer'));
/**
* Mounts a special theme's decor while its scheme is on `<html>`. The decor and
* its stylesheet are their own chunk, so nobody downloads bats they never see.
*
* `intro` is true right after the theme was switched on during this visit,
* which plays the transformation once.
*
* Nothing is drawn over a map. The Atlas world map gets no decor at all; the
* trip planner keeps its map clear and dresses only its side panels (CSS in
* halloween.css, which still has to load there).
*/
const NO_DECOR = [/^\/atlas(\/|$)/];
const PANELS_ONLY = [/^\/trips\/[^/]+\/?$/];
export default function SpecialThemeLayer() {
const scheme = useActiveScheme();
// Armed while another scheme is on; switching to Halloween then plays the
// transformation once (not on a page load that starts in costume, and not
// again every time a map page hands the layer back).
const [armed, setArmed] = useState(scheme !== 'halloween');
if (scheme !== 'halloween' && !armed) setArmed(true);
const onIntro = useCallback(() => setArmed(false), []);
const { pathname } = useLocation();
if (scheme !== 'halloween') return null;
if (NO_DECOR.some((re) => re.test(pathname))) return null;
const panelsOnly = PANELS_ONLY.some((re) => re.test(pathname));
return (
<Suspense fallback={null}>
<HalloweenLayer panelsOnly={panelsOnly} intro={armed && !panelsOnly} onIntro={onIntro} />
</Suspense>
);
}
@@ -0,0 +1,185 @@
/* ── Halloween special theme: tokens ──────────────────────────────────────────
* Loaded eagerly next to index.css so the palette is there on first paint (the
* boot script sets data-scheme before React mounts). The decor itself (webs,
* bats, fog, card dressing) lives in halloween.css and is pulled in with the
* overlay only while the theme is on.
*
* `html[data-scheme]` (0,1,1) outranks the plain `.dark` block (0,1,0), and the
* applier forces `.dark` while this scheme is on, so only one set is needed.
*/
html[data-scheme='halloween'] {
color-scheme: dark;
--bg-primary: #0c0812;
--bg-secondary: #140d1c;
--bg-tertiary: #1b1226;
--bg-elevated: rgba(22, 14, 31, 0.86);
--bg-card: #150e1e;
--bg-input: #1e1429;
--bg-hover: rgba(255, 170, 90, 0.07);
--bg-selected: rgba(249, 115, 22, 0.15);
--text-primary: #f6ecdd;
--text-secondary: #ddcdbb;
--text-muted: #a897b8;
--text-faint: #786a8c;
--border-primary: #2e2040;
--border-secondary: #20162d;
--border-faint: rgba(255, 190, 140, 0.09);
--accent: #f97316;
--accent-text: #1a0b02;
--accent-on: #fb923c;
--accent-hover: #ea580c;
--accent-subtle: rgba(249, 115, 22, 0.16);
--sidebar-bg: rgba(20, 13, 29, 0.88);
--sidebar-shadow: 0 4px 32px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(249, 115, 22, 0.08);
--tooltip-bg: rgba(18, 11, 26, 0.97);
--scrollbar-track: #0f0a16;
--scrollbar-thumb: #3b2752;
--scrollbar-hover: #c2410c;
--success: #84cc16;
--success-soft: rgba(132, 204, 22, 0.16);
--danger: #f43f5e;
--danger-soft: rgba(244, 63, 94, 0.16);
--warning: #fbbf24;
--warning-soft: rgba(251, 191, 36, 0.16);
--info: #a78bfa;
--info-soft: rgba(167, 139, 250, 0.16);
--bg-inverse: #f6ecdd;
--text-inverse: #0c0812;
--overlay: rgba(6, 2, 10, 0.72);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-md: 0 4px 14px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 12px 36px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(249, 115, 22, 0.05);
--shadow-card: 0 2px 10px rgba(0, 0, 0, 0.45);
--shadow-elevated: 0 10px 34px rgba(0, 0, 0, 0.55);
--shadow-modal: 0 18px 64px rgba(0, 0, 0, 0.72), 0 0 48px rgba(249, 115, 22, 0.1);
--shadow-dropdown: 0 10px 28px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(249, 115, 22, 0.08);
--shadow-popover: 0 10px 36px rgba(0, 0, 0, 0.62), 0 0 0 1px rgba(249, 115, 22, 0.08);
--journal-bg: #0a0610;
--journal-card: #170f20;
--journal-border: #2e2040;
--journal-accent: #f97316;
--journal-text: #f6ecdd;
--journal-muted: #a897b8;
--journal-faint: #5c4d70;
--vg-ink: oklch(0.95 0.02 75);
--vg-ink2: oklch(0.8 0.03 300);
--vg-ink3: oklch(0.62 0.04 300);
--vg-bg: oklch(0.15 0.03 300);
--vg-surf: oklch(0.2 0.04 300);
--vg-surf2: oklch(0.24 0.045 300);
--vg-line: oklch(0.3 0.05 300);
--vg-line2: oklch(0.36 0.06 300);
--vg-glass-bg: linear-gradient(135deg, oklch(0.28 0.06 300 / 0.6) 0%, oklch(0.2 0.05 300 / 0.45) 100%);
--vg-glass-br: oklch(0.8 0.12 55 / 0.14);
--vg-glass-sh: 0 1px 2px oklch(0 0 0 / 0.4), 0 12px 32px -14px oklch(0.1 0.05 300 / 0.7);
--vg-hi: inset 0 1px 0 oklch(0.9 0.1 60 / 0.1);
/* The decor's own palette. Everything in halloween.css and the mascot's hat
* reads these, so the look is tuned in one place. */
--hw-night: #07040b;
--hw-dusk: #2a1640;
--hw-pumpkin: #f7811e;
--hw-pumpkin-hi: #ffb347;
--hw-pumpkin-lo: #b4500c;
--hw-stem: #4d7c0f;
--hw-glow: rgba(249, 115, 22, 0.55);
--hw-glow-soft: rgba(249, 115, 22, 0.18);
--hw-ember: #ffcf7a;
--hw-slime: #a3e635;
--hw-moon: #fde8c8;
--hw-web: rgba(232, 222, 255, 0.42);
--hw-web-faint: rgba(232, 222, 255, 0.16);
--hw-hat: #1d1029;
--hw-hat-hi: #3a2152;
--hw-hat-band: #f97316;
--hw-buckle: #fcd34d;
--hw-spider: #0b0710;
--hw-eye: #ff8a1f;
--hw-ghost: rgba(246, 236, 221, 0.92);
--hw-font: 'Creepster', 'Poppins', system-ui, sans-serif;
}
/* Mobile chrome (mobile.css scopes its tokens to .m-root under `.dark`). */
html[data-scheme='halloween'] .m-root {
--m-ink: #f6ecdd;
--m-muted: #b4a3c4;
--m-faint: #7c6d90;
--m-bg: #0a0610;
--m-scr: radial-gradient(120% 70% at 80% -10%, #3a1d52 0%, #1a0e26 40%, #0c0812 70%, #07040b 100%);
--m-glass: rgba(28, 17, 40, 0.62);
--m-gbr: rgba(249, 115, 22, 0.16);
--m-card: rgba(26, 16, 37, 0.6);
--m-cbr: rgba(255, 190, 140, 0.11);
--m-inner: rgba(255, 190, 140, 0.06);
--m-inbr: rgba(255, 190, 140, 0.1);
--m-act: #f97316;
--m-actfg: #1a0b02;
--m-dim: rgba(4, 1, 8, 0.66);
--m-sheet: rgba(22, 13, 31, 0.95);
--m-sheetop: #1a1024;
--m-shbr: rgba(249, 115, 22, 0.18);
--m-ic: rgba(255, 190, 140, 0.08);
--m-avbr: rgba(249, 115, 22, 0.25);
--m-rowbr: rgba(255, 190, 140, 0.07);
--m-conn: #7c6d90;
--m-mapb: #120b19;
--m-mapr: #241733;
--m-mapw: #0b0c16;
--m-mkr: #f97316;
--m-mkfg: #1a0b02;
--m-trackoff: rgba(255, 190, 140, 0.16);
}
html[data-scheme='halloween'] ::selection {
background: rgba(249, 115, 22, 0.42);
color: #fff7ed;
}
/* Dashboard (styles/dashboard.css keeps its own palette under .trek-dash). */
html[data-scheme='halloween'] .trek-dash {
--bg: oklch(0.14 0.03 300);
--bg-2: oklch(0.18 0.04 300);
--surface: oklch(0.2 0.045 300);
--surface-2: oklch(0.23 0.05 300);
--ink: oklch(0.95 0.025 75);
--ink-2: oklch(0.8 0.035 300);
--ink-3: oklch(0.63 0.045 300);
--line: oklch(0.3 0.06 300);
--line-2: oklch(0.36 0.07 300);
--success: oklch(0.78 0.2 130);
--warn: oklch(0.8 0.15 70);
--sh-md: 0 1px 2px oklch(0 0 0 / 0.4), 0 10px 28px -8px oklch(0.1 0.06 300 / 0.7);
--sh-lg: 0 2px 4px oklch(0 0 0 / 0.45), 0 24px 56px -16px oklch(0.1 0.08 300 / 0.8);
--glass-bg: linear-gradient(135deg, oklch(0.27 0.07 300 / 0.62) 0%, oklch(0.19 0.05 300 / 0.48) 100%);
--glass-border: oklch(0.82 0.12 55 / 0.13);
--glass-shadow: 0 1px 2px oklch(0 0 0 / 0.4), 0 14px 34px -14px oklch(0.08 0.06 300 / 0.8);
--glass-shadow-hover: 0 2px 6px oklch(0 0 0 / 0.45), 0 26px 56px -18px oklch(0.6 0.18 50 / 0.35);
--glass-highlight: inset 0 1px 0 oklch(0.92 0.1 70 / 0.1);
background: transparent;
}
html[data-scheme='halloween'] .trek-dash .fab-new-trip {
background: linear-gradient(135deg, #ffb347 0%, #f97316 45%, #c2410c 100%);
color: #1a0b02;
box-shadow:
0 0 0 1px rgba(255, 179, 71, 0.5),
0 8px 22px rgba(249, 115, 22, 0.45),
0 16px 40px rgba(0, 0, 0, 0.5);
}
html[data-scheme='halloween'] .trek-dash .fab-new-trip:hover {
box-shadow:
0 0 0 1px rgba(255, 210, 140, 0.7),
0 10px 30px rgba(249, 115, 22, 0.65),
0 20px 50px rgba(0, 0, 0, 0.55);
}
@@ -0,0 +1,216 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Cobweb } from './art';
import './halloween.css';
import { HalloweenSky, type SkySprites } from './halloweenSky';
import Sprite from './Sprite';
import { SPRITES } from './sprites';
/**
* Everything the Halloween theme hangs over the app, mounted only while
* `html[data-scheme="halloween"]` is set (see `SpecialThemeLayer`).
*
* Two fixed, click-through layers, portalled to <body> so no page's stacking
* context can trap them:
* - the sky (canvas: fog, embers, bats, ghost) above the page content and
* below the navbar (`--z-decor-ambient`);
* - the decor (corner webs, dangling spiders) above the
* navbar and below every menu, dialog and toast (`--z-decor`).
* Only a spider's body takes clicks, so poking one sends it back up its thread.
*/
function readFlags() {
const root = document.documentElement;
const still =
root.hasAttribute('data-reduce-motion') || !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
return { still, noFog: root.hasAttribute('data-no-transparency') };
}
function useMotionFlags() {
const [flags, setFlags] = useState(readFlags);
useEffect(() => {
const update = () => setFlags(readFlags());
const mo = new MutationObserver(update);
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-reduce-motion', 'data-no-transparency'],
});
const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)');
mq?.addEventListener('change', update);
return () => {
mo.disconnect();
mq?.removeEventListener('change', update);
};
}, []);
return flags;
}
function Sky({
intro,
onIntro,
still,
noFog,
}: {
intro: boolean;
onIntro: () => void;
still: boolean;
noFog: boolean;
}) {
const ref = useRef<HTMLCanvasElement>(null);
const sky = useRef<HalloweenSky | null>(null);
const [sprites, setSprites] = useState<SkySprites | null>(null);
useEffect(() => {
let cancelled = false;
const load = (url: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
Promise.all([load(SPRITES.bat.url), load(SPRITES.ghost.url)])
.then(([bat, ghost]) => {
if (!cancelled)
setSprites({ bat: { img: bat, sheet: SPRITES.bat }, ghost: { img: ghost, sheet: SPRITES.ghost } });
})
// A sheet that fails to load leaves the sky out; the rest of the theme stands on its own.
.catch((e: unknown) => console.warn('halloween sprites', e));
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const canvas = ref.current;
if (!canvas || !sprites) return;
try {
sky.current = new HalloweenSky(canvas, { still, noFog }, sprites);
} catch {
// No 2d context (old WebView, headless): the theme still works without its sky.
return;
}
return () => {
sky.current?.destroy();
sky.current = null;
};
}, [still, noFog, sprites]);
// Its own effect so that marking the intro as played does not rebuild the sky mid-swarm.
useEffect(() => {
if (!intro || !sky.current) return;
sky.current.burst();
onIntro();
}, [intro, onIntro, sprites]);
return <canvas ref={ref} className="hw-sky" />;
}
/* ── Dangling spiders ─────────────────────────────────────────────────────── */
type Drop = { id: number; x: number; depth: number; phase: 'down' | 'hang' | 'up'; size: number; offset: number };
const rand = (a: number, b: number) => a + Math.random() * (b - a);
function SpiderDrops({ still }: { still: boolean }) {
const [drops, setDrops] = useState<Drop[]>([]);
const nextId = useRef(1);
useEffect(() => {
if (still) return;
const timers = new Set<number>();
const later = (ms: number, fn: () => void) => {
const t = window.setTimeout(() => {
timers.delete(t);
fn();
}, ms);
timers.add(t);
};
const spawn = () => {
const id = nextId.current++;
const drop: Drop = {
id,
x: rand(8, 92),
depth: rand(18, 46),
phase: 'down',
size: rand(54, 78),
offset: Math.random(),
};
setDrops((d) => [...d.slice(-2), drop]);
// Descend (CSS transition), dangle for a while, climb back, vanish.
later(rand(7000, 11000), () => setDrops((d) => d.map((x) => (x.id === id ? { ...x, phase: 'up' } : x))));
later(16000, () => setDrops((d) => d.filter((x) => x.id !== id)));
later(rand(15000, 30000), spawn);
};
later(2500, spawn);
return () => timers.forEach((t) => window.clearTimeout(t));
}, [still]);
// Next frame after mount: flip 'down' so the transition actually runs.
useEffect(() => {
if (!drops.some((d) => d.phase === 'down')) return;
const raf = requestAnimationFrame(() =>
setDrops((d) => d.map((x) => (x.phase === 'down' ? { ...x, phase: 'hang' } : x)))
);
return () => cancelAnimationFrame(raf);
}, [drops]);
const flee = (id: number) => setDrops((d) => d.map((x) => (x.id === id ? { ...x, phase: 'up' } : x)));
if (still) {
return (
<div
className="hw-drop hw-drop--hang hw-drop--still"
style={{ insetInlineEnd: '14%', ['--hw-depth' as string]: '22vh' }}
>
<Sprite sheet={SPRITES.spider} height={64} still />
</div>
);
}
return (
<>
{drops.map((d) => (
<div
key={d.id}
className={`hw-drop hw-drop--${d.phase}`}
style={{ insetInlineStart: `${d.x}%`, ['--hw-depth' as string]: `${d.depth}vh` }}
>
<button type="button" tabIndex={-1} aria-hidden="true" className="hw-drop-body" onClick={() => flee(d.id)}>
<Sprite sheet={SPRITES.spider} height={d.size} offset={d.offset} />
</button>
</div>
))}
</>
);
}
export default function HalloweenLayer({
intro,
onIntro,
panelsOnly,
}: {
intro: boolean;
onIntro: () => void;
/** Map pages: the stylesheet dresses the side panels, nothing is laid over the map. */
panelsOnly: boolean;
}) {
const { still, noFog } = useMotionFlags();
if (panelsOnly) return null;
return createPortal(
<>
<div className="hw-ambient" aria-hidden="true">
<Sky intro={intro} onIntro={onIntro} still={still} noFog={noFog} />
<div className="hw-vignette" />
</div>
<div className="hw-decor" aria-hidden="true">
<Cobweb size={210} seed={7} className="hw-web hw-web--tl" />
<Cobweb size={180} seed={19} broken className="hw-web hw-web--tr" />
<Cobweb size={120} seed={31} spokes={7} rings={5} className="hw-web hw-web--bl" />
<div className="hw-patch">
<Sprite sheet={SPRITES.pumpkin} height={70} still={still} offset={0.1} className="hw-patch-1" />
<Sprite sheet={SPRITES.pumpkin} height={50} still={still} offset={0.55} className="hw-patch-2" />
<Sprite sheet={SPRITES.pumpkin} height={38} still={still} offset={0.8} className="hw-patch-3" />
</div>
<SpiderDrops still={still} />
</div>
</>,
document.body
);
}
@@ -0,0 +1,67 @@
/**
* The mascot's Halloween costume, drawn in the mascot's own 88×96 viewBox.
*
* The hat sits inside `.trek-body`, so it breathes, hops and nods with the mark;
* its brim follows the head's top bar (≈ y 22, tilted a few degrees) and stays
* clear of every scene prop, which all live at x ≥ 58 or below y 25 on the left.
* The pumpkin stands by the left foot, outside the body, so it stays put while
* the mascot bounces next to it.
*/
/** Witch hat, crooked tip, orange band with a buckle, and a spider on a thread. */
export function MascotHat() {
return (
<g className="hw-mascot-hat" transform="rotate(-6 42 22)">
<g className="hw-mascot-hat-sway">
{/* cone, bent backwards at the tip */}
<path
d="M30.5 21.6 C33.5 15.5 37 10 41 6.2 C44.5 3 49 1.4 54.5 2.6 C50.5 4.4 48.4 6.6 47.6 9.6 C48.4 13.6 50.6 17.6 53.6 21.4 Z"
fill="var(--hw-hat)"
/>
<path d="M41 6.2 C44.5 3 49 1.4 54.5 2.6 C50.8 3.6 47.4 5.2 45 8.4 Z" fill="var(--hw-hat-hi)" opacity="0.8" />
{/* band + buckle */}
<path d="M32.2 18.3 C38.5 17.2 45.5 17 51.8 18.1 L53 21 C46 19.9 38.4 20 31 21.2 Z" fill="var(--hw-hat-band)" />
<rect
x="39.6"
y="17.3"
width="4.6"
height="3.6"
rx="0.6"
fill="none"
stroke="var(--hw-buckle)"
strokeWidth="0.9"
/>
{/* brim */}
<ellipse cx="42.2" cy="22.2" rx="16.6" ry="2.9" fill="var(--hw-hat)" />
<path d="M26 22.4 Q42 25.6 58.4 22.2" stroke="var(--hw-hat-hi)" strokeWidth="0.7" fill="none" />
{/* a spider dangling off the brim's right edge */}
<g className="hw-mascot-spider">
<line x1="57.4" y1="22.6" x2="57.4" y2="33" stroke="var(--hw-web)" strokeWidth="0.35" />
<ellipse cx="57.4" cy="34.2" rx="1.6" ry="1.9" fill="var(--hw-spider)" />
<g stroke="var(--hw-spider)" strokeWidth="0.45" fill="none" strokeLinecap="round">
<path d="M56.1 33.6 L54.4 32.6 M56.1 34.4 L54.2 34.8 M56.2 35.1 L54.8 36.6" />
<path d="M58.7 33.6 L60.4 32.6 M58.7 34.4 L60.6 34.8 M58.6 35.1 L60 36.6" />
</g>
</g>
</g>
</g>
);
}
/** A small lit jack-o'-lantern by the mascot's left foot. */
export function MascotPumpkin({ groundY }: { groundY: number }) {
const y = groundY - 13;
return (
<g className="hw-mascot-pumpkin" transform={`translate(9 ${y})`}>
<path d="M7.4 2.6 C7.2 1.2 7.6 0.4 8.8 -0.4 C9 0.6 8.8 1.6 8.4 2.6 Z" fill="var(--hw-stem)" />
<ellipse cx="4.6" cy="7.6" rx="3.8" ry="5" fill="var(--hw-pumpkin-lo)" />
<ellipse cx="11.4" cy="7.6" rx="3.8" ry="5" fill="var(--hw-pumpkin-lo)" />
<ellipse cx="6.4" cy="7.6" rx="3.6" ry="5.3" fill="var(--hw-pumpkin)" />
<ellipse cx="9.6" cy="7.6" rx="3.6" ry="5.3" fill="var(--hw-pumpkin)" />
<g className="hw-pumpkin-face" fill="var(--hw-ember)">
<path d="M4.4 6.2 L6.2 5 L6.6 7 Z M11.6 6.2 L9.8 5 L9.4 7 Z" />
<path d="M4 9 Q8 12.6 12 9 L10.8 9.6 L10 8.9 L9 10 L8 9 L7 10 L6 8.9 L5.2 9.6 Z" />
</g>
</g>
);
}
@@ -0,0 +1,66 @@
import { useEffect, useRef } from 'react';
import type { SpriteSheet } from './sprites';
/**
* Plays a sprite sheet in a span: steps `background-position` through the
* frames on requestAnimationFrame at the sheet's own fps. `still` holds the
* first frame (reduced motion). `offset` starts the loop part-way through so
* several copies on one screen don't move in lockstep.
*/
export default function Sprite({
sheet,
height,
still = false,
offset = 0,
className = '',
}: {
sheet: SpriteSheet;
height: number;
still?: boolean;
offset?: number;
className?: string;
}) {
const ref = useRef<HTMLSpanElement>(null);
const scale = height / sheet.h;
const fw = sheet.w * scale;
const fh = height;
const rows = Math.ceil(sheet.frames / sheet.cols);
useEffect(() => {
const el = ref.current;
if (!el) return;
const show = (i: number) => {
el.style.backgroundPosition = `${-(i % sheet.cols) * fw}px ${-Math.floor(i / sheet.cols) * fh}px`;
};
const first = Math.floor(offset * sheet.frames) % sheet.frames;
show(first);
if (still) return;
let raf = 0;
let last = -1;
const t0 = performance.now();
const tick = (now: number) => {
raf = requestAnimationFrame(tick);
const i = (first + Math.floor(((now - t0) / 1000) * sheet.fps)) % sheet.frames;
if (i !== last) {
last = i;
show(i);
}
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [sheet, fw, fh, still, offset]);
return (
<span
ref={ref}
aria-hidden="true"
className={`inline-block bg-no-repeat ${className}`}
style={{
width: fw,
height: fh,
backgroundImage: `url(${sheet.url})`,
backgroundSize: `${sheet.cols * fw}px ${rows * fh}px`,
}}
/>
);
}
+114
View File
@@ -0,0 +1,114 @@
/**
* The cobweb, drawn as SVG and coloured from the --hw-* tokens in
* halloween.tokens.css. The figures (ghost, bats, spiders, pumpkins) are
* pre-rendered 3D sprites (sprites.ts); silk is the one thing that stays
* sharpest as vector lines.
*/
/* ── Cobweb ─────────────────────────────────────────────────────────────────
A corner web anchored at (0,0): spokes fan out over a quarter circle and the
capture spiral hangs between them, each strand sagging towards the hub the way
silk does under its own weight. Deterministic per `seed`, so a re-render never
reshuffles the threads. */
function rng(seed: number): () => number {
let s = seed >>> 0 || 1;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
type Web = { spokes: string; rings: string; strays: string; dew: [number, number, number][] };
function cobwebPaths(seed: number, spokes: number, rings: number): Web {
const r = rng(seed);
const angles: number[] = [];
for (let i = 0; i < spokes; i++) {
const t = i / (spokes - 1);
angles.push((t * 90 + (i > 0 && i < spokes - 1 ? (r() - 0.5) * 8 : 0)) * (Math.PI / 180));
}
const len = angles.map(() => 90 + r() * 10);
let spokePath = '';
angles.forEach((a, i) => {
// spokes bow a touch, as anchored silk does
const ex = Math.cos(a) * len[i];
const ey = Math.sin(a) * len[i];
const bow = (r() - 0.5) * 4;
spokePath += `M0 0Q${(ex / 2 - Math.sin(a) * bow).toFixed(1)} ${(ey / 2 + Math.cos(a) * bow).toFixed(1)} ${ex.toFixed(1)} ${ey.toFixed(1)}`;
});
let ringPath = '';
let strays = '';
const dew: [number, number, number][] = [];
for (let k = 1; k <= rings; k++) {
// the capture spiral tightens towards the hub
const base = Math.pow(k / (rings + 0.5), 1.15) * 96;
const pts = angles.map((a) => {
const rr = base * (0.93 + r() * 0.12);
return [Math.cos(a) * rr, Math.sin(a) * rr];
});
// the odd gap: a ring that tore between two spokes
const gap = r() < 0.35 ? 1 + Math.floor(r() * (pts.length - 2)) : -1;
ringPath += `M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
for (let i = 1; i < pts.length; i++) {
const [x0, y0] = pts[i - 1];
const [x1, y1] = pts[i];
const mx = (x0 + x1) / 2;
const my = (y0 + y1) / 2;
const sag = 0.8 - r() * 0.07;
if (i === gap) {
// torn: one end hangs down
strays += `M${x0.toFixed(1)} ${y0.toFixed(1)}Q${(mx * sag).toFixed(1)} ${(my * sag + 6).toFixed(1)} ${(mx + 2).toFixed(1)} ${(my + 10 + r() * 8).toFixed(1)}`;
ringPath += `M${x1.toFixed(1)} ${y1.toFixed(1)}`;
} else {
ringPath += `Q${(mx * sag).toFixed(1)} ${(my * sag).toFixed(1)} ${x1.toFixed(1)} ${y1.toFixed(1)}`;
}
if (r() < 0.12) dew.push([+(mx * (sag + 0.06)).toFixed(1), +(my * (sag + 0.06)).toFixed(1), 0.5 + r() * 0.6]);
}
}
return { spokes: spokePath, rings: ringPath, strays, dew };
}
const WEB_CACHE = new Map<string, Web>();
export function Cobweb({
size,
seed = 7,
spokes = 12,
rings = 11,
className = '',
broken = false,
}: {
size: number;
seed?: number;
spokes?: number;
rings?: number;
className?: string;
/** Leaves a long torn strand dangling off the outer ring. */
broken?: boolean;
}) {
const key = `${seed}:${spokes}:${rings}`;
let web = WEB_CACHE.get(key);
if (!web) {
web = cobwebPaths(seed, spokes, rings);
WEB_CACHE.set(key, web);
}
return (
<svg width={size} height={size} viewBox="-2 -2 104 104" className={className} aria-hidden="true">
<g fill="none" strokeLinecap="round">
{/* a soft second pass under the silk reads as the glow of moonlit thread */}
<path d={web.spokes} stroke="var(--hw-web-faint)" strokeWidth="1.6" />
<path d={web.spokes} stroke="var(--hw-web)" strokeWidth="0.5" />
<path d={web.rings} stroke="var(--hw-web-faint)" strokeWidth="1.1" />
<path d={web.rings} stroke="var(--hw-web)" strokeWidth="0.36" />
<path d={web.strays} stroke="var(--hw-web)" strokeWidth="0.32" />
{broken && <path d="M74 44 Q80 58 76 72 Q74 78 77 84" stroke="var(--hw-web)" strokeWidth="0.35" />}
<g fill="var(--hw-dew, #fff)">
{web.dew.map(([x, y, r], i) => (
<circle key={i} cx={x} cy={y} r={r} className="hw-dew" style={{ animationDelay: `${(i * 0.73) % 5}s` }} />
))}
</g>
</g>
</svg>
);
}
@@ -0,0 +1,591 @@
/* ── Halloween special theme: decor ───────────────────────────────────────────
* Loaded with HalloweenLayer, so only while the theme is on. Every rule is
* scoped to html[data-scheme="halloween"]; the palette lives in
* ../halloween.tokens.css. Motion stops under prefers-reduced-motion and the
* user's own reduce-motion switch; fog and glows go with transparency off.
*/
html[data-scheme='halloween'] {
/* Ambient sky over the content, under the navbar (--z-nav 200). */
--z-decor-ambient: 190;
/* Webs and spiders over the navbar, under its menus (9998) and every dialog. */
--z-decor: 9000;
--hw-card-web: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 66 66'%3E%3Cg fill='none' stroke='rgba(232,222,255,0.34)' stroke-linecap='round'%3E%3Cpath d='M0 0L64.0 0.0M0 0L62.1 15.5M0 0L56.0 31.0M0 0L45.3 45.3M0 0L31.0 56.0M0 0L15.5 62.1M0 0L0.0 64.0' stroke-width='0.7'/%3E%3Cpath d='M13.0 0.0Q10.2 1.3 12.6 3.1Q9.6 3.8 11.4 6.3Q8.2 6.2 9.2 9.2Q6.2 8.2 6.3 11.4Q3.8 9.6 3.1 12.6Q1.3 10.2 0.0 13.0M25.0 0.0Q19.7 2.4 24.3 6.0Q18.4 7.3 21.9 12.1Q15.8 11.9 17.7 17.7Q11.9 15.8 12.1 21.9Q7.3 18.4 6.0 24.3Q2.4 19.7 0.0 25.0M37.0 0.0Q29.2 3.6 35.9 9.0Q27.3 10.8 32.4 17.9Q23.4 17.6 26.2 26.2Q17.6 23.4 17.9 32.4Q10.8 27.3 9.0 35.9Q3.6 29.2 0.0 37.0M50.0 0.0Q39.4 4.8 48.5 12.1Q36.9 14.5 43.7 24.2Q31.6 23.8 35.4 35.4Q23.8 31.6 24.2 43.7Q14.5 36.9 12.1 48.5Q4.8 39.4 0.0 50.0' stroke-width='0.55'/%3E%3C/g%3E%3C/svg%3E");
}
/* ── Night sky behind everything that lets it through ─────────────────────── */
html[data-scheme='halloween'] body {
background:
radial-gradient(70% 55% at 88% -8%, rgba(124, 58, 237, 0.22), transparent 70%),
radial-gradient(60% 40% at 0% 100%, rgba(77, 124, 15, 0.1), transparent 70%), var(--bg-primary);
background-attachment: fixed;
}
/* ── Overlay layers ────────────────────────────────────────────────────────── */
.hw-ambient,
.hw-decor {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.hw-ambient {
z-index: var(--z-decor-ambient);
}
.hw-decor {
z-index: var(--z-decor);
}
.hw-sky {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.hw-vignette {
position: absolute;
inset: 0;
background: radial-gradient(130% 100% at 50% 40%, transparent 62%, rgba(6, 2, 12, 0.5) 100%);
}
/* Corner webs: dew on the silk catches the moon now and then */
.hw-dew {
opacity: 0.35;
animation: hw-twinkle 5s ease-in-out infinite;
}
@keyframes hw-twinkle {
0%,
70%,
100% {
opacity: 0.3;
}
80% {
opacity: 1;
}
}
/* Corner webs */
.hw-web {
position: absolute;
opacity: 0.9;
transform-origin: 0 0;
animation: hw-sway 9s ease-in-out infinite alternate;
filter: drop-shadow(0 0 2px rgba(232, 222, 255, 0.15));
}
.hw-web--tl {
top: 0;
left: 0;
} /* rtl-lint-disable: anchored to the physical viewport corner */
.hw-web--tr {
top: 0;
right: 0; /* rtl-lint-disable: anchored to the physical viewport corner */
transform-origin: 100% 0;
scale: -1 1;
animation-duration: 11s;
animation-delay: -4s;
}
.hw-web--bl {
bottom: var(--bottom-nav-h, 0px);
left: 0; /* rtl-lint-disable: anchored to the physical viewport corner */
transform-origin: 0 100%;
scale: 1 -1;
opacity: 0.55;
animation-duration: 13s;
}
@keyframes hw-sway {
from {
rotate: -1.2deg;
}
to {
rotate: 1.4deg;
}
}
@media (max-width: 767px) {
.hw-web--tl {
width: 120px;
height: 120px;
}
.hw-web--tr {
width: 104px;
height: 104px;
}
.hw-web--bl {
display: none;
}
}
/* Desktop navbar: deep dusk glass with a slow ember running along its edge.
The bar paints itself inline (Navbar.tsx), hence the !important. */
html[data-scheme='halloween'] .trek-navbar {
background: linear-gradient(180deg, rgba(26, 14, 38, 0.92) 0%, rgba(14, 8, 21, 0.9) 100%) !important;
border-bottom-color: rgba(249, 115, 22, 0.14) !important;
box-shadow:
0 10px 30px -12px rgba(0, 0, 0, 0.7),
0 1px 0 rgba(124, 58, 237, 0.18) !important;
}
html[data-scheme='halloween'] .trek-navbar::after {
content: '';
position: absolute;
inset: auto 0 -1px 0;
height: 1px;
pointer-events: none;
background: linear-gradient(
90deg,
transparent 0%,
rgba(249, 115, 22, 0) 30%,
rgba(255, 179, 71, 0.95) 50%,
rgba(249, 115, 22, 0) 70%,
transparent 100%
);
background-size: 40% 100%;
background-repeat: no-repeat;
filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.9));
animation: hw-ember-run 7s cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
@keyframes hw-ember-run {
0% {
background-position: -40% 0;
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
background-position: 140% 0;
opacity: 0;
}
}
html[data-scheme='halloween'] .trek-nav-pill {
background: rgba(124, 58, 237, 0.12) !important;
box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.12);
}
/* A pumpkin patch in the bottom corner, the lanterns lighting the ground. */
.hw-patch {
position: absolute;
bottom: calc(var(--bottom-nav-h, 0px) + 6px);
left: 14px; /* rtl-lint-disable: anchored to the physical viewport corner */
display: flex;
align-items: flex-end;
filter: drop-shadow(0 0 14px rgba(249, 115, 22, 0.35));
}
.hw-patch::before {
content: '';
position: absolute;
inset: auto -30px -10px -30px;
height: 40px;
background: radial-gradient(50% 50% at 50% 60%, rgba(249, 115, 22, 0.28), transparent 70%);
filter: blur(6px);
}
.hw-patch-2 {
margin-inline-start: -16px;
margin-bottom: 2px;
}
.hw-patch-3 {
margin-inline-start: -10px;
}
/* On a phone the corner is content, not floor. */
@media (max-width: 767px) {
.hw-patch {
display: none;
}
}
@media (min-width: 768px) {
.hw-patch {
bottom: 10px;
}
}
/* Dangling spiders */
.hw-drop {
position: absolute;
top: 0;
translate: -50% -120%;
transition: translate 4.6s cubic-bezier(0.22, 0.9, 0.3, 1);
}
.hw-drop::before {
content: '';
position: absolute;
bottom: calc(100% - 2px);
left: 50%; /* rtl-lint-disable: centres the thread on the spider */
width: 1px;
height: 120vh;
background: linear-gradient(
to bottom,
rgba(232, 222, 255, 0),
rgba(232, 222, 255, 0.5) 70%,
rgba(232, 222, 255, 0.7)
);
}
.hw-drop--hang {
translate: -50% var(--hw-depth);
}
.hw-drop--up {
translate: -50% -120%;
transition-duration: 2.4s;
transition-timing-function: cubic-bezier(0.55, 0, 0.8, 0.4);
}
.hw-drop--still {
translate: 50% var(--hw-depth);
transition: none;
}
.hw-drop-body {
display: block;
padding: 0;
border: 0;
background: none;
cursor: pointer;
pointer-events: auto;
transform-origin: 50% 0;
animation: hw-bob 3.2s ease-in-out infinite alternate;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 8px rgba(249, 115, 22, 0.25));
}
.hw-drop-body:focus {
outline: none;
}
@keyframes hw-bob {
from {
rotate: -7deg;
}
to {
rotate: 7deg;
}
}
@keyframes hw-flicker {
0%,
100% {
opacity: 1;
}
41% {
opacity: 0.85;
}
43% {
opacity: 0.45;
}
46% {
opacity: 0.95;
}
72% {
opacity: 0.78;
}
}
/* ── Typography: the display font for the few big titles ──────────────────── */
html[data-scheme='halloween'] .trek-dash .sec-title,
html[data-scheme='halloween'] .hero-content h1,
html[data-scheme='halloween'] .hero-content h2 {
font-family: var(--hw-font);
font-weight: 400;
letter-spacing: 0.03em;
text-shadow: 0 0 18px rgba(249, 115, 22, 0.45);
}
html[data-scheme='halloween'] .trek-dash .sec-title {
color: var(--hw-pumpkin-hi);
}
/* ── Surfaces: webs in the corners, a warmer edge ─────────────────────────── */
html[data-scheme='halloween'] .trip-card,
html[data-scheme='halloween'] .add-trip-card,
html[data-scheme='halloween'] .trek-modal-backdrop > .trek-modal-enter,
html[data-scheme='halloween'] .trek-popover-enter {
position: relative;
}
html[data-scheme='halloween'] .trip-card::after,
html[data-scheme='halloween'] .trek-modal-backdrop > .trek-modal-enter::after {
content: '';
position: absolute;
top: 0;
right: 0; /* rtl-lint-disable: decor anchored to the card corner */
width: 58px;
height: 58px;
background: var(--hw-card-web) no-repeat top right / contain;
transform: scaleX(-1);
pointer-events: none;
z-index: 5;
opacity: 0.85;
}
html[data-scheme='halloween'] .trip-card:nth-child(2n)::after {
right: auto; /* rtl-lint-disable: alternate corner */
left: 0; /* rtl-lint-disable: alternate corner */
transform: none;
}
html[data-scheme='halloween'] .trip-card {
transition:
box-shadow 0.3s ease,
transform 0.3s ease;
}
html[data-scheme='halloween'] .trip-card:hover {
box-shadow:
0 14px 40px -12px rgba(249, 115, 22, 0.45),
0 0 0 1px rgba(249, 115, 22, 0.3);
}
html[data-scheme='halloween'] .trek-menu-enter,
html[data-scheme='halloween'] .trek-popover-enter {
box-shadow:
var(--shadow-popover),
0 0 24px -6px rgba(249, 115, 22, 0.25);
}
/* Primary buttons smoulder on hover. */
html[data-scheme='halloween'] .bg-accent {
transition:
box-shadow 0.25s ease,
background-color 0.2s ease;
}
html[data-scheme='halloween'] .bg-accent:hover {
box-shadow:
0 0 0 1px rgba(255, 179, 71, 0.4),
0 6px 22px -4px var(--hw-glow);
}
/* ── Dashboard hero: moonlight and fog over the cover ─────────────────────── */
html[data-scheme='halloween'] .hero-trip .scrim {
background:
radial-gradient(40% 55% at 86% 18%, rgba(253, 232, 200, 0.28), transparent 60%),
linear-gradient(180deg, rgba(42, 22, 64, 0.35) 0%, rgba(12, 8, 18, 0.15) 40%, rgba(7, 4, 11, 0.88) 100%);
}
html[data-scheme='halloween'] .hero-trip::after {
content: '';
position: absolute;
inset: auto -20% 0 -20%;
height: 55%;
z-index: 1;
pointer-events: none;
background:
radial-gradient(35% 60% at 25% 80%, rgba(170, 130, 220, 0.3), transparent 70%),
radial-gradient(30% 50% at 70% 85%, rgba(120, 170, 120, 0.2), transparent 70%),
radial-gradient(40% 60% at 50% 100%, rgba(150, 110, 200, 0.3), transparent 70%);
filter: blur(10px);
animation: hw-fog-drift 16s ease-in-out infinite alternate;
}
html[data-scheme='halloween'] .hero-trip img.bg {
filter: saturate(0.7) hue-rotate(-12deg) brightness(0.82) contrast(1.08);
}
@keyframes hw-fog-drift {
from {
translate: -6% 0;
}
to {
translate: 6% 0;
}
}
/* Trip covers dim into dusk. */
html[data-scheme='halloween'] .trip-cover img {
filter: saturate(0.75) brightness(0.86) sepia(0.12) hue-rotate(-8deg);
}
/* ── Trip planner: the side panels are dressed, the map between them is not ─ */
html[data-scheme='halloween'] .trek-planner-panel {
position: relative;
box-shadow:
var(--sidebar-shadow),
inset 0 0 0 1px rgba(249, 115, 22, 0.1),
inset 0 -40px 60px -40px rgba(124, 58, 237, 0.35) !important;
}
html[data-scheme='halloween'] .trek-planner-panel::after {
content: '';
position: absolute;
top: 0;
right: 0; /* rtl-lint-disable: decor anchored to the panel corner */
width: 120px;
height: 120px;
background: var(--hw-card-web) no-repeat top right / contain;
transform: scaleX(-1);
pointer-events: none;
z-index: 30;
opacity: 1;
filter: drop-shadow(0 0 2px rgba(232, 222, 255, 0.35)) brightness(1.6);
transform-origin: 100% 0;
animation: hw-panel-web 10s ease-in-out infinite alternate;
}
@keyframes hw-panel-web {
from {
transform: scaleX(-1) rotate(-1deg);
}
to {
transform: scaleX(-1) rotate(1.2deg);
}
}
html[data-scheme='halloween'] .trek-planner-panel::before {
content: '';
position: absolute;
inset: auto -30% 0 -30%;
height: 90px;
pointer-events: none;
z-index: 30;
background:
radial-gradient(40% 70% at 30% 100%, rgba(150, 110, 200, 0.22), transparent 70%),
radial-gradient(35% 60% at 70% 100%, rgba(110, 160, 110, 0.14), transparent 70%);
filter: blur(6px);
animation: hw-fog-drift 14s ease-in-out infinite alternate;
}
/* ── Loading: lanterns instead of grey rings ──────────────────────────────── */
html[data-scheme='halloween'] .animate-spin.rounded-full {
border-color: rgba(124, 58, 237, 0.35);
border-top-color: var(--hw-pumpkin);
box-shadow: 0 0 16px -2px var(--hw-glow);
}
html[data-scheme='halloween'] .trek-skeleton {
background-color: rgba(124, 58, 237, 0.1);
}
html[data-scheme='halloween'] .m-splash-bg {
background:
radial-gradient(40% 40% at 78% 18%, rgba(253, 232, 200, 0.5) 0 12%, rgba(249, 115, 22, 0.25) 13%, transparent 45%),
radial-gradient(90% 70% at 20% 100%, rgba(77, 124, 15, 0.25), transparent 70%),
linear-gradient(165deg, #2a1640 0%, #140a20 55%, #07040b 100%) !important;
}
/* ── Maps: the map itself stays as it is, only the clusters become lanterns ─ */
html[data-scheme='halloween'] .marker-cluster-custom {
background: radial-gradient(
circle at 50% 40%,
var(--hw-pumpkin-hi),
var(--hw-pumpkin) 55%,
var(--hw-pumpkin-lo)
) !important;
color: #1a0b02 !important;
border-color: rgba(26, 11, 2, 0.6) !important;
box-shadow:
0 0 14px var(--hw-glow),
inset 0 -3px 0 rgba(0, 0, 0, 0.18) !important;
border-radius: 46% 46% 50% 50% / 52% 52% 48% 48% !important;
animation: hw-lantern 2.6s ease-in-out infinite;
}
html[data-scheme='halloween'] .marker-cluster-custom::before {
content: '';
position: absolute;
top: -5px;
left: calc(50% - 2px); /* rtl-lint-disable: centred stalk */
width: 4px;
height: 7px;
border-radius: 2px;
background: var(--hw-stem);
}
@keyframes hw-lantern {
0%,
100% {
box-shadow: 0 0 10px var(--hw-glow);
}
50% {
box-shadow: 0 0 22px var(--hw-glow);
}
}
/* ── The mascot in costume ────────────────────────────────────────────────── */
html[data-scheme='halloween'] .trek-pupils {
fill: var(--hw-eye);
}
html[data-scheme='halloween'] svg.trek {
overflow: visible;
}
html[data-scheme='halloween'] svg.trek .trek-body {
filter: drop-shadow(0 0 6px rgba(124, 58, 237, 0.35));
}
.hw-mascot-hat-sway {
transform-box: view-box;
transform-origin: 42px 22px;
animation: hw-hat 3.4s ease-in-out infinite alternate;
}
@keyframes hw-hat {
from {
rotate: -3deg;
}
to {
rotate: 3deg;
}
}
.hw-mascot-spider {
transform-box: view-box;
transform-origin: 57.4px 22.6px;
animation: hw-mascot-spider 2.6s ease-in-out infinite alternate;
}
@keyframes hw-mascot-spider {
from {
rotate: -10deg;
translate: 0 -1px;
}
to {
rotate: 10deg;
translate: 0 1.5px;
}
}
.hw-mascot-pumpkin .hw-pumpkin-face,
.stc-pumpkin .hw-pumpkin-face {
animation: hw-flicker 2.4s infinite;
}
.hw-mascot-pumpkin {
filter: drop-shadow(0 0 3px rgba(249, 115, 22, 0.7));
}
/* ── Scrollbars ───────────────────────────────────────────────────────────── */
html[data-scheme='halloween'] ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #3b2752, #5b2a7a);
}
html[data-scheme='halloween'] ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #c2410c, #f97316);
}
/* ── Quieter when asked ───────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.hw-decor *,
html[data-scheme='halloween'] .trek-navbar::after,
html[data-scheme='halloween'] .trek-planner-panel::before,
html[data-scheme='halloween'] .trek-planner-panel::after,
html[data-scheme='halloween'] .hero-trip::after,
html[data-scheme='halloween'] .marker-cluster-custom,
.hw-mascot-hat-sway,
.hw-mascot-spider,
.hw-mascot-pumpkin .hw-pumpkin-face {
animation: none !important;
transition: none !important;
}
}
html[data-reduce-motion] .hw-decor *,
html[data-reduce-motion][data-scheme='halloween'] .trek-planner-panel::before,
html[data-reduce-motion][data-scheme='halloween'] .trek-planner-panel::after,
html[data-reduce-motion][data-scheme='halloween'] .trek-navbar::after,
html[data-reduce-motion][data-scheme='halloween'] .hero-trip::after,
html[data-reduce-motion][data-scheme='halloween'] .marker-cluster-custom,
html[data-reduce-motion] .hw-mascot-hat-sway,
html[data-reduce-motion] .hw-mascot-spider,
html[data-reduce-motion] .hw-mascot-pumpkin .hw-pumpkin-face {
animation: none !important;
transition: none !important;
}
html[data-no-transparency] .hw-vignette,
html[data-no-transparency][data-scheme='halloween'] .hero-trip::after {
display: none;
}
@@ -0,0 +1,350 @@
import type { SpriteSheet } from './sprites';
/**
* The Halloween ambient layer: one full-viewport canvas over the page content
* (under the navbar, menus and dialogs) that draws low fog, rising embers, bat
* flocks, a ghost now and then and the odd flash of lightning.
*
* Everything that can be is pre-rendered once into sprites, the loop runs at
* ~30 fps and stops while the tab is hidden. With `still` (reduced motion) it
* paints the fog once and never starts the loop.
*/
type Fog = { x: number; y: number; r: number; vx: number; a: number; sprite: number; phase: number };
type Ember = { x: number; y: number; vy: number; sway: number; phase: number; size: number; life: number };
type Bat = { x: number; y: number; vx: number; vy: number; size: number; wobble: number; seed: number };
type GhostState = { x: number; y: number; vx: number; t: number; dur: number; size: number };
const FRAME_MS = 1000 / 30;
const DPR_CAP = 1.5;
function sprite(size: number, paint: (ctx: CanvasRenderingContext2D, s: number) => void): HTMLCanvasElement {
const c = document.createElement('canvas');
c.width = c.height = size;
const ctx = c.getContext('2d');
if (ctx) paint(ctx, size);
return c;
}
/** A lumpy fog puff: a handful of overlapping radial blobs in one colour. */
function fogSprite(rgb: string, seed: number): HTMLCanvasElement {
return sprite(256, (ctx, s) => {
let k = seed;
const rnd = () => (k = (k * 9301 + 49297) % 233280) / 233280;
for (let i = 0; i < 7; i++) {
const cx = s * (0.3 + rnd() * 0.4);
const cy = s * (0.35 + rnd() * 0.3);
const r = s * (0.18 + rnd() * 0.22);
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, `rgba(${rgb},0.55)`);
g.addColorStop(0.6, `rgba(${rgb},0.18)`);
g.addColorStop(1, `rgba(${rgb},0)`);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
});
}
function glowSprite(): HTMLCanvasElement {
return sprite(32, (ctx, s) => {
const g = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
g.addColorStop(0, 'rgba(255,214,140,1)');
g.addColorStop(0.25, 'rgba(255,150,50,0.65)');
g.addColorStop(1, 'rgba(255,110,20,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, s, s);
});
}
/** The pre-rendered 3D sheets the sky draws from, loaded by the caller. */
export interface SkySprites {
bat: { img: HTMLImageElement; sheet: SpriteSheet };
ghost: { img: HTMLImageElement; sheet: SpriteSheet };
}
function drawFrame(
ctx: CanvasRenderingContext2D,
{ img, sheet }: { img: HTMLImageElement; sheet: SpriteSheet },
frame: number,
w: number
) {
const i = Math.floor(frame) % sheet.frames;
const sx = (i % sheet.cols) * sheet.w;
const sy = Math.floor(i / sheet.cols) * sheet.h;
const h = (w * sheet.h) / sheet.w;
ctx.drawImage(img, sx, sy, sheet.w, sheet.h, -w / 2, -h / 2, w, h);
}
export interface HalloweenSkyOptions {
/** Paint once, never animate (reduced motion). */
still: boolean;
/** Skip the fog (transparency off). */
noFog: boolean;
}
export class HalloweenSky {
private ctx: CanvasRenderingContext2D;
private w = 0;
private h = 0;
private dpr = 1;
private raf = 0;
private last = 0;
private time = 0;
private fog: Fog[] = [];
private embers: Ember[] = [];
private bats: Bat[] = [];
private ghost: GhostState | null = null;
private flash = 0;
private nextFlock = 6;
private nextGhost = 25;
private nextBolt = 40;
private fogSprites: HTMLCanvasElement[];
private glow: HTMLCanvasElement;
private stopped = false;
constructor(
private canvas: HTMLCanvasElement,
private opts: HalloweenSkyOptions,
private sprites: SkySprites
) {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2d context unavailable');
this.ctx = ctx;
this.fogSprites = [fogSprite('120,70,180', 3), fogSprite('96,150,96', 11), fogSprite('150,110,200', 29)];
this.glow = glowSprite();
this.resize();
this.seed();
window.addEventListener('resize', this.resize);
document.addEventListener('visibilitychange', this.onVisibility);
if (opts.still) this.paint();
else this.start();
}
destroy() {
this.stopped = true;
cancelAnimationFrame(this.raf);
window.removeEventListener('resize', this.resize);
document.removeEventListener('visibilitychange', this.onVisibility);
}
/** The transformation moment: lightning plus a swarm bursting out of the middle. */
burst() {
if (this.opts.still) return;
this.flash = 1;
const cx = this.w / 2;
const cy = this.h * 0.42;
for (let i = 0; i < 26; i++) {
const a = (i / 26) * Math.PI * 2 + Math.random() * 0.3;
const sp = 260 + Math.random() * 320;
this.bats.push({
x: cx + Math.cos(a) * 20,
y: cy + Math.sin(a) * 20,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp * 0.7 - 40,
size: 12 + Math.random() * 18,
wobble: Math.random() * 6,
seed: Math.random() * 100,
});
}
}
private onVisibility = () => {
if (this.opts.still) return;
if (document.hidden) cancelAnimationFrame(this.raf);
else this.start();
};
private resize = () => {
this.dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP);
this.w = window.innerWidth;
this.h = window.innerHeight;
this.canvas.width = Math.round(this.w * this.dpr);
this.canvas.height = Math.round(this.h * this.dpr);
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
if (this.opts.still) this.paint();
};
private seed() {
const compact = this.w < 700;
const fogCount = compact ? 5 : 9;
this.fog = Array.from({ length: fogCount }, (_, i) => ({
x: Math.random() * this.w,
y: this.h * (0.72 + Math.random() * 0.34),
r: (compact ? 220 : 340) + Math.random() * 220,
vx: (8 + Math.random() * 14) * (i % 2 ? 1 : -1),
a: 0.1 + Math.random() * 0.08,
sprite: i % this.fogSprites.length,
phase: Math.random() * 6,
}));
this.embers = Array.from({ length: compact ? 12 : 24 }, () => this.newEmber(true));
}
private newEmber(anywhere: boolean): Ember {
return {
x: Math.random() * this.w,
y: anywhere ? this.h * (0.4 + Math.random() * 0.6) : this.h + 10,
vy: 14 + Math.random() * 26,
sway: 10 + Math.random() * 22,
phase: Math.random() * 6,
size: 1 + Math.random() * 1.8,
life: 0,
};
}
private spawnFlock() {
const fromLeft = Math.random() < 0.5;
const n = 3 + Math.floor(Math.random() * 5);
const y0 = this.h * (0.12 + Math.random() * 0.45);
const sp = 120 + Math.random() * 80;
for (let i = 0; i < n; i++) {
this.bats.push({
x: fromLeft ? -40 - i * 36 - Math.random() * 30 : this.w + 40 + i * 36 + Math.random() * 30,
y: y0 + (Math.random() - 0.5) * 90,
vx: (fromLeft ? 1 : -1) * (sp + Math.random() * 40),
vy: (Math.random() - 0.5) * 30,
size: 9 + Math.random() * 12,
wobble: Math.random() * 6,
seed: Math.random() * 100,
});
}
}
private start() {
if (this.stopped) return;
cancelAnimationFrame(this.raf);
this.last = performance.now();
const tick = (now: number) => {
if (this.stopped) return;
this.raf = requestAnimationFrame(tick);
const elapsed = now - this.last;
if (elapsed < FRAME_MS) return;
this.last = now;
this.step(Math.min(elapsed / 1000, 0.1));
this.paint();
};
this.raf = requestAnimationFrame(tick);
}
private step(dt: number) {
this.time += dt;
for (const f of this.fog) {
f.x += f.vx * dt;
if (f.x < -f.r) f.x = this.w + f.r;
if (f.x > this.w + f.r) f.x = -f.r;
}
for (let i = 0; i < this.embers.length; i++) {
const e = this.embers[i];
e.life += dt;
e.y -= e.vy * dt;
if (e.y < this.h * 0.25) this.embers[i] = this.newEmber(false);
}
for (const b of this.bats) {
b.wobble += dt * 2.2;
b.x += b.vx * dt;
b.y += (b.vy + Math.sin(b.wobble + b.seed) * 40) * dt;
}
this.bats = this.bats.filter((b) => b.x > -200 && b.x < this.w + 200 && b.y > -200 && b.y < this.h + 200);
this.nextFlock -= dt;
if (this.nextFlock <= 0) {
this.spawnFlock();
this.nextFlock = 16 + Math.random() * 18;
}
this.nextGhost -= dt;
if (!this.ghost && this.nextGhost <= 0) {
const ltr = Math.random() < 0.5;
this.ghost = {
x: ltr ? -80 : this.w + 80,
y: this.h * (0.3 + Math.random() * 0.4),
vx: (ltr ? 1 : -1) * (50 + Math.random() * 30),
t: 0,
dur: (this.w + 160) / 65,
size: 70 + Math.random() * 40,
};
this.nextGhost = 45 + Math.random() * 40;
}
if (this.ghost) {
const g = this.ghost;
g.t += dt;
g.x += g.vx * dt;
if (g.t > g.dur + 2) this.ghost = null;
}
this.nextBolt -= dt;
if (this.nextBolt <= 0) {
this.flash = 0.45;
this.nextBolt = 70 + Math.random() * 60;
}
if (this.flash > 0) this.flash = Math.max(0, this.flash - dt * 1.8);
}
private paint() {
const { ctx, w, h } = this;
ctx.clearRect(0, 0, w, h);
if (!this.opts.noFog) {
ctx.globalCompositeOperation = 'lighter';
for (const f of this.fog) {
const breathe = 0.85 + Math.sin(this.time * 0.4 + f.phase) * 0.15;
ctx.globalAlpha = f.a * breathe;
const s = f.r * 2;
ctx.drawImage(this.fogSprites[f.sprite], f.x - f.r, f.y - f.r * 0.6, s, s * 0.6);
}
ctx.globalCompositeOperation = 'source-over';
}
if (!this.opts.still) {
ctx.globalCompositeOperation = 'lighter';
for (const e of this.embers) {
const flicker = 0.5 + Math.sin(e.life * 9 + e.phase) * 0.3;
const fade = Math.min(1, (e.y - h * 0.25) / (h * 0.3));
ctx.globalAlpha = Math.max(0, flicker * fade) * 0.8;
const x = e.x + Math.sin(e.life * 1.3 + e.phase) * e.sway;
const s = e.size * 7;
ctx.drawImage(this.glow, x - s / 2, e.y - s / 2, s, s);
}
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
if (this.ghost) {
const g = this.ghost;
const fadeIn = Math.min(1, g.t / 2);
const fadeOut = Math.min(1, Math.max(0, (g.dur - g.t) / 2));
const fade = fadeIn * fadeOut;
const y = g.y + Math.sin(g.t * 1.6) * 14;
ctx.save();
ctx.translate(g.x, y);
// a cold halo first, then the ghost itself, half there
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = 0.18 * fade;
const halo = g.size * 2.2;
ctx.drawImage(this.glow, -halo / 2, -halo / 2, halo, halo);
ctx.globalCompositeOperation = 'source-over';
if (g.vx < 0) ctx.scale(-1, 1);
ctx.rotate(Math.sin(g.t * 1.1) * 0.06);
ctx.globalAlpha = 0.62 * fade;
drawFrame(ctx, this.sprites.ghost, this.time * this.sprites.ghost.sheet.fps, g.size);
ctx.restore();
ctx.globalAlpha = 1;
}
for (const b of this.bats) {
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(Math.atan2(b.vy, Math.abs(b.vx)) * 0.35 * Math.sign(b.vx || 1));
if (b.vx < 0) ctx.scale(-1, 1);
const rate = this.sprites.bat.sheet.fps * (0.85 + (b.seed % 1) * 0.3);
drawFrame(ctx, this.sprites.bat, this.time * rate + b.seed, b.size * 2.4);
ctx.restore();
}
if (this.flash > 0) {
ctx.globalAlpha = this.flash * 0.22;
ctx.fillStyle = '#d9ccff';
ctx.fillRect(0, 0, w, h);
ctx.globalAlpha = 1;
}
}
}
}
@@ -0,0 +1,25 @@
/**
* Pre-rendered 3D sprites (Three.js scenes, rendered offline with an alpha channel):
* each sheet is one seamless loop, `cols` frames per row, read left to right, top to bottom.
*/
import batUrl from './sprites/bat.webp';
import ghostUrl from './sprites/ghost.webp';
import pumpkinUrl from './sprites/pumpkin.webp';
import spiderUrl from './sprites/spider.webp';
export interface SpriteSheet {
url: string;
frames: number;
cols: number;
/** Frame size in px. */
w: number;
h: number;
fps: number;
}
export const SPRITES = {
ghost: { url: ghostUrl, frames: 64, cols: 8, w: 145, h: 200, fps: 20 },
bat: { url: batUrl, frames: 14, cols: 8, w: 180, h: 122, fps: 25 },
spider: { url: spiderUrl, frames: 40, cols: 8, w: 169, h: 180, fps: 16.7 },
pumpkin: { url: pumpkinUrl, frames: 24, cols: 8, w: 180, h: 154, fps: 12 },
} satisfies Record<string, SpriteSheet>;
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

@@ -0,0 +1,381 @@
/* ── Special theme picker card ────────────────────────────────────────────────
* A tiny moonlit scene. At rest it glows softly; on hover the night sky starts
* to churn (an animated gradient), a conic border runs round the edge, the bats
* take off, the ghost rises and the lanterns flare.
*/
@property --stc-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.stc--halloween {
/* The decor palette, set on the card so it previews before the theme is on. */
--hw-night: #07040b;
--hw-pumpkin: #f7811e;
--hw-pumpkin-hi: #ffb347;
--hw-pumpkin-lo: #b4500c;
--hw-stem: #4d7c0f;
--hw-ember: #ffcf7a;
--hw-web: rgba(232, 222, 255, 0.5);
--hw-web-faint: rgba(232, 222, 255, 0.2);
--hw-spider: #0b0710;
--hw-eye: #ff8a1f;
--hw-ghost: rgba(246, 236, 221, 0.9);
}
.stc {
position: relative;
isolation: isolate;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 84px;
padding: 10px 14px;
border-radius: 12px;
overflow: hidden;
text-align: start;
color: #fff7ed;
cursor: pointer;
background: #0b0612;
box-shadow:
0 8px 30px -12px rgba(124, 58, 237, 0.55),
0 0 0 1px rgba(249, 115, 22, 0.22);
transition:
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.35s ease;
}
.stc:hover,
.stc:focus-visible {
transform: translateY(-2px) scale(1.01);
box-shadow:
0 18px 50px -14px rgba(249, 115, 22, 0.6),
0 0 0 1px rgba(249, 115, 22, 0.5);
outline: none;
}
.stc:active {
transform: translateY(0) scale(0.99);
}
/* Running conic border: masked to a 1.5px ring. */
.stc-border {
position: absolute;
inset: 0;
z-index: 3;
border-radius: inherit;
padding: 1.5px;
background: conic-gradient(
from var(--stc-angle),
#f97316,
#7c3aed 25%,
#a3e635 45%,
#f97316 60%,
#db2777 80%,
#f97316
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box exclude,
linear-gradient(#000 0 0);
opacity: 0;
transition: opacity 0.4s ease;
pointer-events: none;
}
.stc:hover .stc-border,
.stc:focus-visible .stc-border,
.stc--active .stc-border {
opacity: 1;
animation: stc-spin 3.2s linear infinite;
}
@keyframes stc-spin {
to {
--stc-angle: 360deg;
}
}
.stc-sky {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(120% 90% at 82% 0%, rgba(124, 58, 237, 0.55) 0%, transparent 55%),
linear-gradient(165deg, #2a1640 0%, #140a20 50%, #07040b 100%);
}
/* The churning gradient that wakes up on hover. */
.stc-aurora {
position: absolute;
inset: -40%;
background: linear-gradient(
115deg,
rgba(249, 115, 22, 0) 10%,
rgba(249, 115, 22, 0.55) 30%,
rgba(124, 58, 237, 0.6) 48%,
rgba(163, 230, 53, 0.35) 62%,
rgba(219, 39, 119, 0.5) 78%,
rgba(249, 115, 22, 0) 92%
);
background-size: 260% 260%;
mix-blend-mode: screen;
filter: blur(18px);
opacity: 0.18;
transition: opacity 0.5s ease;
animation: stc-aurora 9s ease-in-out infinite alternate;
}
.stc:hover .stc-aurora,
.stc:focus-visible .stc-aurora {
opacity: 0.85;
animation-duration: 3.5s;
}
@keyframes stc-aurora {
0% {
background-position: 0% 50%;
transform: rotate(0deg);
}
100% {
background-position: 100% 50%;
transform: rotate(8deg);
}
}
.stc-moon {
position: absolute;
top: 9px;
inset-inline-end: 16px;
width: 26px;
height: 26px;
border-radius: 50%;
background:
radial-gradient(circle at 34% 38%, rgba(180, 140, 100, 0.35) 0 9%, transparent 10%),
radial-gradient(circle at 62% 64%, rgba(180, 140, 100, 0.3) 0 7%, transparent 8%),
radial-gradient(circle at 60% 30%, rgba(180, 140, 100, 0.25) 0 5%, transparent 6%),
radial-gradient(circle at 40% 40%, #fff6e4, #fde8c8 55%, #f9c98a 100%);
box-shadow:
0 0 14px 4px rgba(253, 232, 200, 0.32),
0 0 36px 10px rgba(249, 115, 22, 0.16);
transition:
transform 0.6s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.6s ease;
}
.stc:hover .stc-moon {
transform: translateY(-3px) scale(1.08);
box-shadow:
0 0 30px 10px rgba(253, 232, 200, 0.5),
0 0 90px 30px rgba(249, 115, 22, 0.3);
}
.stc-fog {
position: absolute;
inset: auto -20% -30% -20%;
height: 70%;
background:
radial-gradient(40% 60% at 25% 60%, rgba(150, 110, 200, 0.35), transparent 70%),
radial-gradient(35% 50% at 70% 70%, rgba(110, 160, 110, 0.22), transparent 70%);
filter: blur(8px);
animation: stc-fog 12s ease-in-out infinite alternate;
}
@keyframes stc-fog {
to {
transform: translateX(10%);
}
}
.stc-web {
position: absolute;
top: 0;
opacity: 0.8;
}
.stc-web--l {
inset-inline-start: 0;
}
.stc-web--r {
inset-inline-end: 0;
transform: scaleX(-1);
opacity: 0.55;
}
.stc-bats {
position: absolute;
inset: 0;
}
.stc-bat {
position: absolute;
transition:
transform 1.1s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.6s ease;
}
.stc-bat--1 {
top: 12px;
inset-inline-end: 54px;
}
.stc-bat--2 {
top: 34px;
inset-inline-end: 84px;
opacity: 0.8;
}
.stc-bat--3 {
top: 8px;
inset-inline-end: 112px;
opacity: 0.7;
}
.stc:hover .stc-bat--1 {
transform: translate(-40px, -14px) rotate(-8deg);
}
.stc:hover .stc-bat--2 {
transform: translate(-60px, -22px) rotate(-12deg);
}
.stc:hover .stc-bat--3 {
transform: translate(-30px, -8px) rotate(6deg);
}
.stc-ghost {
position: absolute;
bottom: -40px;
inset-inline-start: 44%;
opacity: 0;
filter: drop-shadow(0 0 10px rgba(220, 210, 255, 0.7));
transition:
transform 1s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.6s ease;
}
.stc:hover .stc-ghost {
opacity: 0.7;
transform: translateY(-46px) rotate(-6deg);
}
.stc-pumpkins {
position: absolute;
bottom: 4px;
inset-inline-end: 10px;
display: flex;
align-items: flex-end;
gap: 2px;
}
.stc-pumpkin {
filter: drop-shadow(0 0 8px rgba(249, 115, 22, 0.45));
transition:
filter 0.4s ease,
transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.stc:hover .stc-pumpkin {
filter: drop-shadow(0 0 16px rgba(249, 115, 22, 0.9));
}
.stc:hover .stc-pumpkin--1 {
transform: translateY(-4px) rotate(-6deg);
}
.stc:hover .stc-pumpkin--2 {
transform: translateY(-2px) rotate(8deg);
}
.stc-copy {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
max-width: 60%;
}
.stc-title {
font-family: 'Creepster', 'Poppins', system-ui, sans-serif;
font-size: calc(21px * var(--fs-scale-title, 1));
line-height: 1;
letter-spacing: 0.04em;
background: linear-gradient(90deg, #ffb347, #f97316 40%, #fde68a 60%, #f97316 80%, #ffb347);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 2px 10px rgba(249, 115, 22, 0.55));
}
.stc:hover .stc-title {
animation: stc-title 1.6s linear infinite;
}
@keyframes stc-title {
to {
background-position: 200% 0;
}
}
.stc-hint {
font-size: calc(11px * var(--fs-scale-caption, 1));
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
color: rgba(246, 236, 221, 0.78);
}
.stc-badge {
position: relative;
z-index: 2;
display: inline-flex;
flex: none;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 999px;
font-size: calc(10.5px * var(--fs-scale-caption, 1));
font-weight: 600;
color: #fff7ed;
background: rgba(20, 10, 30, 0.6);
box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.45);
backdrop-filter: blur(6px);
position: absolute;
top: 8px;
inset-inline-end: 50px;
}
.stc-badge--on {
color: #1a0b02;
background: linear-gradient(90deg, #f97316, #fb923c);
box-shadow: 0 0 18px rgba(249, 115, 22, 0.6);
}
.stc--active {
box-shadow:
0 14px 44px -12px rgba(249, 115, 22, 0.65),
0 0 0 1px rgba(249, 115, 22, 0.6);
}
@media (max-width: 520px) {
.stc {
min-height: 80px;
}
.stc-copy {
max-width: 68%;
}
}
@media (prefers-reduced-motion: reduce) {
.stc,
.stc * {
animation: none !important;
transition: none !important;
}
}
html[data-reduce-motion] .stc,
html[data-reduce-motion] .stc * {
animation: none !important;
transition: none !important;
}
+10
View File
@@ -33,6 +33,7 @@
"hasInstallScript": true,
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/creepster": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/inter": "^5.3.0",
@@ -3224,6 +3225,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/creepster": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/creepster/-/creepster-5.3.0.tgz",
"integrity": "sha512-k1Y03cPd2QnoVBygqfYd/y1Byx1Rl3vDLuq3TrmujwaSgFh8hFy2e1ar7/zhillaaCwweLDI9AlvhIBA+VlsWg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/eb-garamond": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/eb-garamond/-/eb-garamond-5.3.0.tgz",
@@ -25,8 +25,21 @@ export const APPEARANCE_PRESET_SCHEMES = [
'rose', // warm rose/coral
'amber', // warm gold/sunrise
'violet', // purple/plum
'halloween', // seasonal special theme: always dark, adds the spooky decor layer
] as const;
/**
* Special themes are more than a palette: they carry their own decor layer and
* only exist in a dark variant, so the applier forces `.dark` while one is on.
*/
export const APPEARANCE_SPECIAL_SCHEMES = [
'halloween',
] as const satisfies readonly (typeof APPEARANCE_PRESET_SCHEMES)[number][];
export function isSpecialScheme(id: string): boolean {
return (APPEARANCE_SPECIAL_SCHEMES as readonly string[]).includes(id);
}
/** All selectable scheme ids, including the user-defined custom accent. */
export const APPEARANCE_SCHEME_IDS = [...APPEARANCE_PRESET_SCHEMES, 'custom'] as const;
+6
View File
@@ -420,6 +420,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Amber',
'settings.appearance.scheme.violet': 'Violet',
'settings.appearance.scheme.custom': 'Custom',
'settings.appearance.scheme.halloween': 'هالوين',
'settings.appearance.special': 'سمات خاصة',
'settings.appearance.special.halloween.hint': 'داكن دائمًا، مع خيوط العنكبوت والخفافيش والضباب والتميمة بزيّ تنكري.',
'settings.appearance.special.active': 'نشط',
'settings.appearance.special.try': 'جرّبه',
'settings.appearance.special.modeLocked': 'السمات الخاصة داكنة دائمًا.',
'settings.appearance.customAccent': 'Custom accent',
'settings.appearance.contrastOk': 'Good contrast',
'settings.appearance.contrastLow': 'Low contrast',
+6
View File
@@ -480,6 +480,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Kəhrəba',
'settings.appearance.scheme.violet': 'Bənövşəyi',
'settings.appearance.scheme.custom': 'Fərdi',
'settings.appearance.scheme.halloween': 'Halloween', // same-as-en
'settings.appearance.special': 'Xüsusi temalar',
'settings.appearance.special.halloween.hint': 'Həmişə qaranlıq: hörümçək toru, yarasalar, duman və kostyumlu maskot.',
'settings.appearance.special.active': 'Aktiv',
'settings.appearance.special.try': 'Sına',
'settings.appearance.special.modeLocked': 'Xüsusi temalar həmişə qaranlıqdır.',
'settings.appearance.customAccent': 'Fərdi vurğu rəngi',
'settings.appearance.contrastOk': 'Yaxşı kontrast',
'settings.appearance.contrastLow': 'Aşağı kontrast',
+6
View File
@@ -433,6 +433,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Amber',
'settings.appearance.scheme.violet': 'Violet',
'settings.appearance.scheme.custom': 'Custom',
'settings.appearance.scheme.halloween': 'Halloween', // same-as-en
'settings.appearance.special': 'Temas especiais',
'settings.appearance.special.halloween.hint': 'Sempre escuro, com teias de aranha, morcegos, névoa e o mascote fantasiado.',
'settings.appearance.special.active': 'Ativo',
'settings.appearance.special.try': 'Experimentar',
'settings.appearance.special.modeLocked': 'Temas especiais são sempre escuros.',
'settings.appearance.customAccent': 'Custom accent',
'settings.appearance.contrastOk': 'Good contrast',
'settings.appearance.contrastLow': 'Low contrast',
+6
View File
@@ -409,6 +409,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Ambre',
'settings.appearance.scheme.violet': 'Violeta',
'settings.appearance.scheme.custom': 'Personalitzat',
'settings.appearance.scheme.halloween': 'Halloween', // same-as-en
'settings.appearance.special': 'Temes especials',
'settings.appearance.special.halloween.hint': 'Sempre fosc, amb teranyines, ratpenats, boira i la mascota disfressada.',
'settings.appearance.special.active': 'Actiu',
'settings.appearance.special.try': 'Prova-ho',
'settings.appearance.special.modeLocked': 'Els temes especials sempre són foscos.',
'settings.appearance.customAccent': "Color d'accent personalitzat",
'settings.appearance.contrastOk': 'Bon contrast',
'settings.appearance.contrastLow': 'Contrast baix',
+6
View File
@@ -426,6 +426,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Amber',
'settings.appearance.scheme.violet': 'Violet',
'settings.appearance.scheme.custom': 'Custom',
'settings.appearance.scheme.halloween': 'Halloween', // same-as-en
'settings.appearance.special': 'Speciální motivy',
'settings.appearance.special.halloween.hint': 'Vždy tmavý, s pavučinami, netopýry, mlhou a maskotem v kostýmu.',
'settings.appearance.special.active': 'Aktivní',
'settings.appearance.special.try': 'Vyzkoušet',
'settings.appearance.special.modeLocked': 'Speciální motivy jsou vždy tmavé.',
'settings.appearance.customAccent': 'Custom accent',
'settings.appearance.contrastOk': 'Good contrast',
'settings.appearance.contrastLow': 'Low contrast',
+6
View File
@@ -436,6 +436,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Bernstein',
'settings.appearance.scheme.violet': 'Violett',
'settings.appearance.scheme.custom': 'Eigene',
'settings.appearance.scheme.halloween': 'Halloween', // same-as-en
'settings.appearance.special': 'Special Themes',
'settings.appearance.special.halloween.hint': 'Immer dunkel, mit Spinnweben, Fledermäusen, Nebel und dem Maskottchen im Kostüm.',
'settings.appearance.special.active': 'Aktiv',
'settings.appearance.special.try': 'Ausprobieren',
'settings.appearance.special.modeLocked': 'Special Themes sind immer dunkel.',
'settings.appearance.customAccent': 'Eigene Akzentfarbe',
'settings.appearance.contrastOk': 'Guter Kontrast',
'settings.appearance.contrastLow': 'Niedriger Kontrast',
+6
View File
@@ -434,6 +434,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Amber',
'settings.appearance.scheme.violet': 'Violet',
'settings.appearance.scheme.custom': 'Custom',
'settings.appearance.scheme.halloween': 'Halloween',
'settings.appearance.special': 'Special themes',
'settings.appearance.special.halloween.hint': 'Always dark, with cobwebs, bats, fog and the mascot in costume.',
'settings.appearance.special.active': 'Active',
'settings.appearance.special.try': 'Try it',
'settings.appearance.special.modeLocked': 'Special themes are always dark.',
'settings.appearance.customAccent': 'Custom accent',
'settings.appearance.contrastOk': 'Good contrast',
'settings.appearance.contrastLow': 'Low contrast',
+6
View File
@@ -434,6 +434,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Amber',
'settings.appearance.scheme.violet': 'Violet',
'settings.appearance.scheme.custom': 'Custom',
'settings.appearance.scheme.halloween': 'Halloween', // same-as-en
'settings.appearance.special': 'Temas especiales',
'settings.appearance.special.halloween.hint': 'Siempre oscuro, con telarañas, murciélagos, niebla y la mascota disfrazada.',
'settings.appearance.special.active': 'Activo',
'settings.appearance.special.try': 'Probar',
'settings.appearance.special.modeLocked': 'Los temas especiales siempre son oscuros.',
'settings.appearance.customAccent': 'Custom accent',
'settings.appearance.contrastOk': 'Good contrast',
'settings.appearance.contrastLow': 'Low contrast',
+6
View File
@@ -438,6 +438,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Merevaigukollane',
'settings.appearance.scheme.violet': 'Violetne',
'settings.appearance.scheme.custom': 'Kohandatud',
'settings.appearance.scheme.halloween': 'Halloween', // same-as-en
'settings.appearance.special': 'Eriteemad',
'settings.appearance.special.halloween.hint': 'Alati tume, ämblikuvõrkude, nahkhiirte, udu ja kostümeeritud maskotiga.',
'settings.appearance.special.active': 'Aktiivne',
'settings.appearance.special.try': 'Proovi',
'settings.appearance.special.modeLocked': 'Eriteemad on alati tumedad.',
'settings.appearance.customAccent': 'Kohandatud aktsentvärv',
'settings.appearance.contrastOk': 'Hea kontrast',
'settings.appearance.contrastLow': 'Madal kontrast',
+6
View File
@@ -441,6 +441,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Amber',
'settings.appearance.scheme.violet': 'Violet',
'settings.appearance.scheme.custom': 'Custom',
'settings.appearance.scheme.halloween': 'Halloween', // same-as-en
'settings.appearance.special': 'Thèmes spéciaux',
'settings.appearance.special.halloween.hint': 'Toujours sombre, avec toiles d’araignée, chauves-souris, brume et la mascotte déguisée.',
'settings.appearance.special.active': 'Actif',
'settings.appearance.special.try': 'Essayer',
'settings.appearance.special.modeLocked': 'Les thèmes spéciaux sont toujours sombres.',
'settings.appearance.customAccent': 'Custom accent',
'settings.appearance.contrastOk': 'Good contrast',
'settings.appearance.contrastLow': 'Low contrast',
+6
View File
@@ -444,6 +444,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Amber',
'settings.appearance.scheme.violet': 'Violet',
'settings.appearance.scheme.custom': 'Custom',
'settings.appearance.scheme.halloween': 'Χάλοουιν',
'settings.appearance.special': 'Ειδικά θέματα',
'settings.appearance.special.halloween.hint': 'Πάντα σκοτεινό, με ιστούς αράχνης, νυχτερίδες, ομίχλη και τη μασκότ μεταμφιεσμένη.',
'settings.appearance.special.active': 'Ενεργό',
'settings.appearance.special.try': 'Δοκίμασέ το',
'settings.appearance.special.modeLocked': 'Τα ειδικά θέματα είναι πάντα σκοτεινά.',
'settings.appearance.customAccent': 'Custom accent',
'settings.appearance.contrastOk': 'Good contrast',
'settings.appearance.contrastLow': 'Low contrast',
+6
View File
@@ -433,6 +433,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Amber',
'settings.appearance.scheme.violet': 'Violet',
'settings.appearance.scheme.custom': 'Custom',
'settings.appearance.scheme.halloween': 'Halloween', // same-as-en
'settings.appearance.special': 'Különleges témák',
'settings.appearance.special.halloween.hint': 'Mindig sötét, pókhálókkal, denevérekkel, köddel és jelmezes kabalával.',
'settings.appearance.special.active': 'Aktív',
'settings.appearance.special.try': 'Kipróbálom',
'settings.appearance.special.modeLocked': 'A különleges témák mindig sötétek.',
'settings.appearance.customAccent': 'Custom accent',
'settings.appearance.contrastOk': 'Good contrast',
'settings.appearance.contrastLow': 'Low contrast',
+6
View File
@@ -430,6 +430,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Amber',
'settings.appearance.scheme.violet': 'Violet',
'settings.appearance.scheme.custom': 'Custom',
'settings.appearance.scheme.halloween': 'Halloween', // same-as-en
'settings.appearance.special': 'Tema spesial',
'settings.appearance.special.halloween.hint': 'Selalu gelap, dengan sarang laba-laba, kelelawar, kabut, dan maskot berkostum.',
'settings.appearance.special.active': 'Aktif',
'settings.appearance.special.try': 'Coba',
'settings.appearance.special.modeLocked': 'Tema spesial selalu gelap.',
'settings.appearance.customAccent': 'Custom accent',
'settings.appearance.contrastOk': 'Good contrast',
'settings.appearance.contrastLow': 'Low contrast',
+6
View File
@@ -432,6 +432,12 @@ const settings: TranslationStrings = {
'settings.appearance.scheme.amber': 'Amber',
'settings.appearance.scheme.violet': 'Violet',
'settings.appearance.scheme.custom': 'Custom',
'settings.appearance.scheme.halloween': 'Halloween', // same-as-en
'settings.appearance.special': 'Temi speciali',
'settings.appearance.special.halloween.hint': 'Sempre scuro, con ragnatele, pipistrelli, nebbia e la mascotte in costume.',
'settings.appearance.special.active': 'Attivo',
'settings.appearance.special.try': 'Provalo',
'settings.appearance.special.modeLocked': 'I temi speciali sono sempre scuri.',
'settings.appearance.customAccent': 'Custom accent',
'settings.appearance.contrastOk': 'Good contrast',
'settings.appearance.contrastLow': 'Low contrast',

Some files were not shown because too many files have changed in this diff Show More