mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 /
|
||||
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+10
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user