万圣节&圣诞节皮肤

This commit is contained in:
eafonyang
2026-09-30 17:48:32 +08:00
parent 13827634e7
commit 7d62f3ec70
31 changed files with 1020 additions and 25 deletions
+47 -10
View File
@@ -17,6 +17,8 @@ import { Bot, Home, MessageSquare } from "lucide-react";
import { Link, useLocation } from "wouter";
import { useMemo } from "react";
import { bottomNavInnerClass, bottomNavOuterClass } from "@/lib/appShell";
import FestivalNavIcon from "@/components/festival/FestivalNavIcon";
import { useFestival, type FestivalId } from "@/lib/festival";
type BottomNavLocale = NonNullable<(typeof localeZh)["bottomNav"]>;
@@ -24,14 +26,43 @@ interface NavItem {
path: string;
icon: React.ComponentType<{ size?: number; className?: string }>;
labelKey: keyof BottomNavLocale;
/** 各节日替换 lucide 图标的贴纸素材,键为节日 ID。 */
festivalIcons?: Partial<Record<FestivalId, string>>;
}
const FORUM_URL = "https://forum.zidoo.tv/index.php";
const festivalIcon = (festival: FestivalId, name: string) =>
`${import.meta.env.BASE_URL}images/festival/${festival}/${name}.png`;
const NAV_ITEMS: NavItem[] = [
{ path: "/community", icon: MessageSquare, labelKey: "community" },
{ path: "/", icon: Home, labelKey: "home" },
{ path: "/ai", icon: Bot, labelKey: "myAi" },
{
path: "/community",
icon: MessageSquare,
labelKey: "community",
festivalIcons: {
christmas: festivalIcon("christmas", "bell"),
halloween: festivalIcon("halloween", "ghost"),
},
},
{
path: "/",
icon: Home,
labelKey: "home",
festivalIcons: {
christmas: festivalIcon("christmas", "santa"),
halloween: festivalIcon("halloween", "pumpkin"),
},
},
{
path: "/ai",
icon: Bot,
labelKey: "myAi",
festivalIcons: {
christmas: festivalIcon("christmas", "gift"),
halloween: festivalIcon("halloween", "vampire"),
},
},
];
export default function BottomNav() {
@@ -39,6 +70,7 @@ export default function BottomNav() {
const isLgUp = useIsLgUp();
const { open: aiDrawerOpen, setOpen: setAiDrawerOpen } = useAIDrawer();
const { deviceState } = useDevice();
const festival = useFestival();
const navText = useMemo((): BottomNavLocale => {
const pack = resolveLocalePack(deviceState?.language);
@@ -67,6 +99,7 @@ export default function BottomNav() {
(item.path === "/" && location === "/") ||
(item.path !== "/" && location.startsWith(item.path));
const Icon = item.icon;
const festivalSrc = festival ? item.festivalIcons?.[festival] : undefined;
const inner = (
<div className="flex flex-col items-center justify-center relative pt-2.5 pb-1.5 px-3">
@@ -92,13 +125,17 @@ export default function BottomNav() {
background: isActive ? "rgba(0,255,246,0.1)" : "transparent",
}}
>
<Icon
size={isActive ? 20 : 19}
className={cn(
"transition-all duration-200",
isActive ? "text-[#00FFF6]" : "text-white/30"
)}
/>
{festivalSrc ? (
<FestivalNavIcon src={festivalSrc} active={isActive} />
) : (
<Icon
size={isActive ? 20 : 19}
className={cn(
"transition-all duration-200",
isActive ? "text-[#00FFF6]" : "text-white/30"
)}
/>
)}
</div>
{/* Label */}
+37 -2
View File
@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils";
import { useRef, type CSSProperties } from "react";
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
/** Generous hit area around the thumb for touch. */
const THUMB_HIT_RADIUS_PX = 24;
@@ -42,6 +42,12 @@ export interface ThumbOnlyRangeSliderProps {
onRelease?: (value: number) => void;
onDragStart?: () => void;
onDragEnd?: () => void;
/**
* Optional custom thumb (e.g. festival skin). When provided the native round
* thumb is hidden and this node is rendered as an overlay centered on the
* thumb position; the slider still owns all drag/hit-test logic.
*/
renderThumb?: () => ReactNode;
}
/**
@@ -66,12 +72,28 @@ export default function ThumbOnlyRangeSlider({
onRelease,
onDragStart,
onDragEnd,
renderThumb,
}: ThumbOnlyRangeSliderProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const dragRef = useRef<{ pointerId: number; grabOffsetPx: number } | null>(null);
const valueRef = useRef(value);
valueRef.current = value;
// Track width (px) so a custom overlay thumb can be centered exactly like the
// native thumb (which insets its center by THUMB_HALF_WIDTH_PX at both ends).
const [trackW, setTrackW] = useState(0);
useLayoutEffect(() => {
const el = inputRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const en of entries) setTrackW(en.contentRect.width);
});
ro.observe(el);
setTrackW(el.getBoundingClientRect().width);
return () => ro.disconnect();
}, []);
const thumbCenterPx = trackW > 0 ? thumbCenterXPx(trackW, min, max, value) : null;
const finishDrag = (pointerId: number | null) => {
const drag = dragRef.current;
if (!drag) return;
@@ -133,7 +155,7 @@ export default function ThumbOnlyRangeSlider({
max={max}
value={value}
disabled={disabled}
className="cyan-slider block w-full"
className={cn("cyan-slider block w-full", renderThumb && "cyan-slider--no-thumb")}
style={{ pointerEvents: "none", touchAction: "none" }}
onChange={(e) => {
// Only reachable via keyboard — the input no longer receives pointer input.
@@ -157,6 +179,19 @@ export default function ThumbOnlyRangeSlider({
}
}}
/>
{renderThumb && (
<div
aria-hidden
className="pointer-events-none absolute top-1/2 z-20"
style={
thumbCenterPx != null
? { left: thumbCenterPx, transform: "translate(-50%, -50%)" }
: { visibility: "hidden" }
}
>
{renderThumb()}
</div>
)}
</div>
);
}
@@ -0,0 +1,30 @@
import { cn } from "@/lib/utils";
const SRC = `${import.meta.env.BASE_URL}images/festival/christmas/bell.png`;
/**
* Christmas skin: jingle-bell icon that replaces the min/max volume speakers.
* `dim` renders the smaller, muted-looking variant used for the min button.
*/
export default function ChristmasBellIcon({
dim = false,
className,
}: {
dim?: boolean;
className?: string;
}) {
return (
<span className="festival-bell-slot" aria-hidden="true">
<img
src={SRC}
alt=""
draggable={false}
className={cn(
"pointer-events-none select-none object-contain",
dim ? "festival-bell-dim h-5 w-5 opacity-70" : "festival-bell h-7 w-7",
className,
)}
/>
</span>
);
}
@@ -0,0 +1,94 @@
import { memo } from "react";
const LIGHT_POSITIONS = [4, 17, 30, 43, 56, 69, 82, 95];
/** 设备卡片的静态圣诞装饰:只占上沿,不接收点击或参与布局。 */
export default memo(function ChristmasCardDecoration() {
return (
<div className="christmas-card-decoration" aria-hidden="true">
<svg
className="christmas-card-snow christmas-card-snow-left"
viewBox="0 0 76 12"
focusable="false"
>
<path
d="M0 10Q3 0 15 1L65 1Q75 1 76 3Q70 7 60 5Q54 4 49 7Q43 9 36 6Q29 4 23 7Q18 10 13 7Q6 6 0 10Z"
fill="#e8eff1"
/>
<path d="M10 3Q30 1 49 3T69 3" fill="none" stroke="#fff" strokeWidth="1.5" />
</svg>
<svg
className="christmas-card-snow christmas-card-snow-right"
viewBox="0 0 60 10"
focusable="false"
>
<path
d="M0 2Q10 0 21 1L46 1Q56 0 60 8Q54 5 49 7Q42 9 36 6Q29 3 23 6Q17 8 12 5Q5 5 0 2Z"
fill="#e8eff1"
/>
</svg>
<div className="christmas-card-lights">
<svg
className="christmas-card-wire"
viewBox="0 0 100 18"
preserveAspectRatio="none"
focusable="false"
>
<path
d="M0 2Q50 15 100 2"
fill="none"
stroke="#77623c"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
</svg>
{LIGHT_POSITIONS.map((position) => {
const ratio = position / 100;
// 与灯线的二次曲线保持一致;灯泡固定尺寸,不随宽屏拉伸。
const top = 2 + 26 * ratio * (1 - ratio);
return (
<span
key={position}
className="christmas-card-light"
style={{ left: `${position}%`, top }}
>
<span className="christmas-card-bulb" />
</span>
);
})}
</div>
<svg
className="christmas-card-sprig"
viewBox="0 0 48 44"
fill="none"
focusable="false"
>
{/* 松枝与针叶保持小尺寸的辨识度,不覆盖下方旋钮。 */}
<g strokeLinecap="round" strokeLinejoin="round">
<path d="M27 13L6 34M28 13L44 32M28 14L29 39" stroke="#567b50" strokeWidth="1.8" />
<path
d="M23 17L13 15M23 17L25 26M18 22L7 21M18 22L21 32M13 27L4 28M13 27L14 36M9 31L3 33"
stroke="#356749"
strokeWidth="2.8"
/>
<path
d="M32 18L32 26M32 18L40 17M37 23L37 32M37 23L45 23M41 28L41 35M41 28L47 29M29 23L24 30M29 27L34 34M29 32L25 39"
stroke="#43805a"
strokeWidth="2.4"
/>
<path d="M20 19L14 19M15 24L9 25M34 21L39 21M29 30L30 36" stroke="#8da57a" strokeWidth="1" />
</g>
{/* 红丝带的两翼、飘带与结心均为静态。 */}
<path d="M25 14L18 29L18 23L13 24L22 12M28 14L37 26L31 24L30 30L25 16" fill="#a92f39" stroke="#71242b" strokeWidth="1" />
<path d="M24 13C17 3 9 3 11 11C12 18 19 18 24 13Z" fill="#d3484f" stroke="#8f2933" strokeWidth="1.2" />
<path d="M27 13C30 3 40 2 40 10C39 17 33 17 27 13Z" fill="#c73a46" stroke="#8f2933" strokeWidth="1.2" />
<path d="M14 8Q17 7 21 12M30 11Q35 6 37 8" stroke="#ef8b83" strokeWidth="1.4" strokeLinecap="round" />
<rect x="22" y="10" width="8" height="8" rx="3" fill="#e45b5b" stroke="#962b35" strokeWidth="1" />
<circle cx="23" cy="26" r="2.1" fill="#d95150" />
<circle cx="27" cy="29" r="1.7" fill="#b83240" />
</svg>
</div>
);
});
@@ -0,0 +1,19 @@
import { memo } from "react";
const SRC = `${import.meta.env.BASE_URL}images/festival/christmas/santa.png`;
/**
* Christmas skin: running-Santa overlay that replaces the volume slider thumb.
* Positioning is handled by ThumbOnlyRangeSlider (renderThumb); this component
* only supplies the artwork + running animation.
*/
export default memo(function ChristmasVolumeThumb() {
return (
<img
src={SRC}
alt=""
draggable={false}
className="festival-santa-thumb pointer-events-none select-none"
/>
);
});
@@ -0,0 +1,100 @@
import { useLayoutEffect, useRef, useState } from "react";
import { FESTIVAL_GREETINGS, type FestivalId } from "@/lib/festival";
import "./festival.css";
/**
* VU 区的静态插画横幅:两端角色保持比例,中央背景平铺,文字独立缩放。
* 按容器宽度切换横排/双行,保留顶部设备信息;不参与布局、不拦截点击。
*/
export default function FestivalGreeting({
festival,
}: {
festival: FestivalId;
}) {
const boxRef = useRef<HTMLDivElement>(null);
const textBoxRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const artworkRoot = `${import.meta.env.BASE_URL}images/festival/${festival}/`;
useLayoutEffect(() => {
const box = boxRef.current;
const textBox = textBoxRef.current;
const inner = innerRef.current;
if (!box || !textBox || !inner) return;
const status = box.parentElement?.querySelector<HTMLElement>(
".festival-vu-status"
);
let disposed = false;
const fit = () => {
if (disposed) return;
// 先应用排版再测量;使用容器宽度,兼容横屏、分屏及 legacy。
box.dataset.layout = box.clientWidth >= 420 ? "wide" : "compact";
// 极窄屏的格式和音量分成两行,标题跟随实际状态栏高度下移。
textBox.style.top = `${Math.max(17, status?.offsetHeight ?? 0) + 2}px`;
const bw = textBox.clientWidth - 8;
const bh = textBox.clientHeight - 4;
const tw = inner.offsetWidth;
const th = inner.offsetHeight;
if (bw <= 0 || bh <= 0 || tw <= 0 || th <= 0) return;
setScale(Math.min(bw / tw, bh / th));
};
fit();
const ro =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(fit);
ro?.observe(box);
ro?.observe(inner);
if (status) ro?.observe(status);
window.addEventListener("resize", fit);
// 字体就绪后重新测量,避免初次回退字体导致文字越界。
void document.fonts?.ready.then(fit);
return () => {
disposed = true;
ro?.disconnect();
window.removeEventListener("resize", fit);
};
}, [festival]);
return (
<div
ref={boxRef}
className={`festival-greeting festival-greeting--${festival}`}
role="img"
aria-label={FESTIVAL_GREETINGS[festival]}
>
<div
className="festival-greeting-backdrop"
style={{ backgroundImage: `url("${artworkRoot}banner-center.jpg")` }}
aria-hidden="true"
/>
{(["left", "right"] as const).map(side => (
<img
key={side}
src={`${artworkRoot}banner-${side}.jpg`}
className={`festival-greeting-art festival-greeting-art--${side}`}
alt=""
aria-hidden="true"
draggable={false}
/>
))}
<div className="festival-greeting-shade" aria-hidden="true" />
<div
ref={textBoxRef}
className="festival-greeting-title"
aria-hidden="true"
>
<div
ref={innerRef}
className={`${festival}-greeting-text festival-greeting-inner`}
style={{ transform: `scale(${scale})` }}
>
{FESTIVAL_GREETINGS[festival].split(" ").map(word => (
<span key={word} className="festival-greeting-line">
{word}
</span>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import { cn } from "@/lib/utils";
import "./festival.css";
/**
* 底部导航栏的节日贴纸图标:只替换 lucide 图标的视觉,尺寸与原图标一致(22px),
* 选中态用青色辉光表达、未选中态压暗降饱和,不改变导航布局与点击行为。
*/
export default function FestivalNavIcon({
src,
active = false,
}: {
src: string;
active?: boolean;
}) {
return (
<img
src={src}
alt=""
draggable={false}
aria-hidden="true"
className={cn(
"festival-nav-icon pointer-events-none select-none",
active ? "festival-nav-icon-active" : "festival-nav-icon-idle",
)}
/>
);
}
@@ -0,0 +1,100 @@
import { memo } from "react";
const BAT_POSITIONS = [8, 24, 40, 56, 72, 88];
/** 设备卡片的静态万圣节装饰:两角蜘蛛网、倒挂蝙蝠串、右上黑猫;只占上沿,不接收点击或参与布局。 */
export default memo(function HalloweenCardDecoration() {
return (
<div className="halloween-card-decoration" aria-hidden="true">
<svg
className="halloween-card-web halloween-card-web-left"
viewBox="0 0 34 30"
fill="none"
focusable="false"
>
<g stroke="#d7dee0" strokeOpacity="0.5" strokeWidth="0.8">
<path d="M0 0L34 6M0 0L30 15M0 0L20 25M0 0L8 30" />
<path d="M12 0Q11.2 5.6 7.6 9Q4.4 12 0 13" />
<path d="M22 0Q20.8 10.4 13.8 16.8Q7.6 22.4 0 24" />
<path d="M32 0Q30.4 15.2 20 24.6Q10.8 32.8 0 34" />
</g>
</svg>
<svg
className="halloween-card-web halloween-card-web-right"
viewBox="0 0 34 30"
fill="none"
focusable="false"
>
<g stroke="#d7dee0" strokeOpacity="0.5" strokeWidth="0.8">
<path d="M0 0L34 6M0 0L30 15M0 0L20 25M0 0L8 30" />
<path d="M12 0Q11.2 5.6 7.6 9Q4.4 12 0 13" />
<path d="M22 0Q20.8 10.4 13.8 16.8Q7.6 22.4 0 24" />
<path d="M32 0Q30.4 15.2 20 24.6Q10.8 32.8 0 34" />
</g>
</svg>
<div className="halloween-card-bats">
<svg
className="halloween-card-wire"
viewBox="0 0 100 18"
preserveAspectRatio="none"
focusable="false"
>
<path
d="M0 2Q50 15 100 2"
fill="none"
stroke="#4d4358"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
</svg>
{BAT_POSITIONS.map((position) => {
const ratio = position / 100;
// 与挂线的二次曲线保持一致;蝙蝠固定尺寸,不随宽屏拉伸。
const top = 2 + 26 * ratio * (1 - ratio);
return (
<span
key={position}
className="halloween-card-bat"
style={{ left: `${position}%`, top }}
>
<svg viewBox="0 0 10 5" fill="#6d5a80" focusable="false">
<path d="M0 2.6Q1.8 0.4 3.4 1.8Q4.2 0.6 5 1.6Q5.8 0.6 6.6 1.8Q8.2 0.4 10 2.6Q8.4 2.4 7.4 3.6Q6.4 4.8 5 4.6Q3.6 4.8 2.6 3.6Q1.6 2.4 0 2.6Z" />
</svg>
</span>
);
})}
</div>
<svg
className="halloween-card-cat"
viewBox="0 0 40 36"
fill="none"
focusable="false"
>
{/* 黑猫剪影:暗底上用细描边勾轮廓,琥珀色眼睛点睛。 */}
<g fill="#101114" stroke="rgba(255,255,255,0.28)" strokeWidth="0.8">
<path d="M15 7L13.5 1.5L18.5 4.5Z" />
<path d="M25 7L26.5 1.5L21.5 4.5Z" />
<circle cx="20" cy="10" r="6" />
<path d="M13 32Q12 18 16 14Q20 12 24 14Q28 18 27 32Z" />
</g>
<path
d="M27 30Q34 28 33 20Q33 16 30 15"
stroke="#101114"
strokeWidth="3"
strokeLinecap="round"
/>
<path
d="M27 30Q34 28 33 20Q33 16 30 15"
stroke="rgba(255,255,255,0.22)"
strokeWidth="0.8"
strokeLinecap="round"
fill="none"
/>
<path d="M16.6 9.6Q18.1 8.2 19.6 9.6Q18.1 11 16.6 9.6Z" fill="#ffb347" />
<path d="M20.6 9.6Q22.1 8.2 23.6 9.6Q22.1 11 20.6 9.6Z" fill="#ffb347" />
</svg>
</div>
);
});
@@ -0,0 +1,27 @@
import { cn } from "@/lib/utils";
const SRC = `${import.meta.env.BASE_URL}images/festival/halloween/pumpkin.png`;
/** 万圣节音量两端的南瓜灯:与铃铛一样固定 24px 占位,视觉尺寸独立于布局。 */
export default function HalloweenPumpkinIcon({
dim = false,
className,
}: {
dim?: boolean;
className?: string;
}) {
return (
<span className="halloween-pumpkin-slot" aria-hidden="true">
<img
src={SRC}
alt=""
draggable={false}
className={cn(
"pointer-events-none select-none object-contain",
dim ? "halloween-pumpkin-dim h-5 w-5 opacity-70" : "halloween-pumpkin h-7 w-7",
className,
)}
/>
</span>
);
}
@@ -0,0 +1,15 @@
import { memo } from "react";
const SRC = `${import.meta.env.BASE_URL}images/festival/halloween/witch.png`;
/** 万圣节音量条 thumb:骑扫帚的巫婆静态贴图,与圣诞老人 thumb 同尺寸。 */
export default memo(function HalloweenVolumeThumb() {
return (
<img
src={SRC}
alt=""
draggable={false}
className="halloween-witch-thumb pointer-events-none select-none"
/>
);
});
@@ -0,0 +1,132 @@
/* modern 与 legacy 共用;只装饰圣诞组件,不改变音量条的几何与交互。 */
.cyan-slider.cyan-slider--no-thumb::-webkit-slider-thumb {
background: transparent;
box-shadow: none;
}
.cyan-slider.cyan-slider--no-thumb::-moz-range-thumb {
background: transparent;
box-shadow: none;
}
.festival-santa-thumb {
width: 44px;
height: 44px;
object-fit: contain;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
}
.festival-bell {
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
}
.festival-bell-dim {
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5)) saturate(0.7);
}
/* 占位与原喇叭保持 24px 一致,大小铃铛仅在视觉层变化。 */
.festival-bell-slot {
display: block;
position: relative;
width: 24px;
height: 24px;
pointer-events: none;
}
.festival-bell-slot > img {
position: absolute;
max-width: none;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* 装饰层限于卡片顶部;所有子元素穿透点击且不进入辅助技术阅读顺序。 */
.christmas-card-decoration {
position: absolute;
top: 0;
right: 0;
left: 0;
height: 42px;
z-index: 1;
pointer-events: none;
user-select: none;
}
.christmas-card-decoration * {
pointer-events: none;
}
.christmas-card-snow {
position: absolute;
top: 0;
height: auto;
}
.christmas-card-snow-left {
left: 3px;
width: 70px;
}
.christmas-card-snow-right {
right: 3px;
width: 54px;
}
/* 圣诞横幅:墨绿雪景中的暖金手写花体。 */
.festival-greeting--christmas {
background: #061a13;
}
.christmas-greeting-text {
font-family: "Snell Roundhand", "Savoye LET", "Segoe Script", "Brush Script MT", cursive;
font-size: 64px;
font-style: italic;
letter-spacing: 0.5px;
color: #ffe6a0;
text-shadow:
0 0 2px #fff6d5,
0 0 6px rgba(255, 191, 63, 0.9),
0 0 15px rgba(228, 139, 30, 0.65),
0 2px 2px #63310b;
}
.christmas-card-lights {
position: absolute;
top: 1px;
left: 18px;
right: 46px;
height: 18px;
}
.christmas-card-wire {
display: block;
width: 100%;
height: 18px;
}
.christmas-card-light {
position: absolute;
width: 1px;
height: 3px;
background: #8c744b;
}
.christmas-card-bulb {
position: absolute;
top: 2px;
left: -2px;
width: 5px;
height: 7px;
border-radius: 2px 2px 50% 50%;
background: radial-gradient(ellipse at 45% 35%, #fff8df, #f3c56d 65%, #c18b3e);
box-shadow: 0 0 5px 1px rgba(245, 192, 92, 0.3);
}
.christmas-card-sprig {
position: absolute;
top: 0;
right: 2px;
width: 44px;
height: 40px;
}
+158
View File
@@ -0,0 +1,158 @@
/* modern / legacy 共用的静态横幅与导航贴纸;不改变设备卡片几何。 */
.festival-greeting {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
pointer-events: none;
user-select: none;
z-index: 1;
}
.festival-greeting *,
.festival-vu-status {
pointer-events: none;
}
/* 状态信息始终盖在插画上,顶部暗色渐变保证小字可读。 */
.festival-vu-status {
position: relative;
z-index: 2;
flex-wrap: wrap;
column-gap: 4px;
padding-left: 4px;
padding-right: 4px;
background: linear-gradient(rgba(0, 0, 0, 0.72), transparent);
text-shadow: 0 1px 3px #000;
}
.festival-vu-status > span {
flex: none;
max-width: 100%;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: rgba(255, 250, 224, 0.88);
}
.festival-vu-status > span + span {
margin-left: auto;
}
.festival-greeting-backdrop,
.festival-greeting-shade {
position: absolute;
inset: 0;
}
.festival-greeting-backdrop {
background-size: auto 100%;
background-position: center;
background-repeat: repeat-x;
}
/* 两侧插画不随横屏宽度拉伸;渐隐接缝融入中央夜空。 */
.festival-greeting-art {
position: absolute;
top: 0;
width: 150px;
max-width: 50%;
height: 100%;
object-fit: cover;
opacity: 0.5;
}
.festival-greeting-art--left {
left: 0;
object-position: left center;
-webkit-mask-image: linear-gradient(to right, #000 65%, transparent);
mask-image: linear-gradient(to right, #000 65%, transparent);
}
.festival-greeting-art--right {
right: 0;
object-position: right center;
-webkit-mask-image: linear-gradient(to left, #000 65%, transparent);
mask-image: linear-gradient(to left, #000 65%, transparent);
}
.festival-greeting-shade {
background: linear-gradient(
90deg,
transparent,
rgba(0, 0, 0, 0.38) 40%,
rgba(0, 0, 0, 0.38) 60%,
transparent
);
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.3);
}
.festival-greeting-title {
position: absolute;
top: 17px;
bottom: 5px;
left: 6px;
right: 6px;
display: flex;
align-items: center;
justify-content: center;
}
/* 自然宽度测量不受 flex 收缩影响,缩放只作用于文字。 */
.festival-greeting-inner {
display: flex;
flex: none;
flex-direction: column;
align-items: center;
line-height: 1.05;
white-space: nowrap;
transform-origin: center;
}
.festival-greeting-line {
display: block;
}
/* 以 VU 容器宽度判断横排,不依赖视口媒体查询或容器查询支持。 */
.festival-greeting[data-layout="wide"] .festival-greeting-art {
max-width: 30%;
opacity: 1;
}
.festival-greeting[data-layout="wide"] .festival-greeting-title {
left: min(26%, 150px);
right: min(26%, 150px);
}
.festival-greeting[data-layout="wide"] .festival-greeting-inner {
flex-direction: row;
}
.festival-greeting[data-layout="wide"]
.festival-greeting-line
+ .festival-greeting-line {
margin-left: 0.22em;
}
/* 导航贴纸与 lucide 图标同一 22px 视觉尺寸。 */
.festival-nav-icon {
width: 22px;
height: 22px;
max-width: none;
object-fit: contain;
transition:
filter 0.2s ease,
opacity 0.2s ease,
transform 0.2s ease;
}
.festival-nav-icon-active {
transform: scale(1.08);
filter: drop-shadow(0 0 6px rgba(0, 255, 246, 0.65));
}
.festival-nav-icon-idle {
opacity: 0.55;
filter: saturate(0.75);
}
@@ -0,0 +1,120 @@
/* modern 与 legacy 共用;只装饰万圣节组件,不改变音量条的几何与交互。 */
/* 万圣节横幅:紫黑夜空中的南瓜火光字。 */
.festival-greeting--halloween {
background: #120b1c;
}
.halloween-greeting-text {
font-family: "Chiller", "Trattatello", "Herculanum", "Papyrus", fantasy;
font-size: 64px;
letter-spacing: 2px;
color: #ff9b32;
text-shadow:
0 -1px 1px #ffe294,
0 1px 2px #9b2609,
0 0 6px rgba(255, 104, 16, 0.85),
0 -3px 14px rgba(232, 65, 8, 0.7),
0 2px 3px #170407;
}
.halloween-witch-thumb {
width: 44px;
height: 44px;
object-fit: contain;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
}
.halloween-pumpkin {
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
}
.halloween-pumpkin-dim {
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5)) saturate(0.7);
}
/* 占位与原喇叭保持 24px 一致,大小南瓜仅在视觉层变化。 */
.halloween-pumpkin-slot {
display: block;
position: relative;
width: 24px;
height: 24px;
pointer-events: none;
}
.halloween-pumpkin-slot > img {
position: absolute;
max-width: none;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* 装饰层限于卡片顶部;所有子元素穿透点击且不进入辅助技术阅读顺序。 */
.halloween-card-decoration {
position: absolute;
top: 0;
right: 0;
left: 0;
height: 42px;
z-index: 1;
pointer-events: none;
user-select: none;
}
.halloween-card-decoration * {
pointer-events: none;
}
.halloween-card-web {
position: absolute;
top: 0;
width: 34px;
height: 30px;
}
.halloween-card-web-left {
left: 0;
}
.halloween-card-web-right {
right: 0;
transform: scaleX(-1);
}
.halloween-card-bats {
position: absolute;
top: 1px;
left: 40px;
right: 46px;
height: 18px;
}
.halloween-card-wire {
display: block;
width: 100%;
height: 18px;
}
/* 1px 吊线 + 固定尺寸蝙蝠剪影。 */
.halloween-card-bat {
position: absolute;
width: 1px;
height: 2px;
background: #4d4358;
}
.halloween-card-bat > svg {
position: absolute;
top: 2px;
left: -4.5px;
width: 10px;
height: 5px;
}
.halloween-card-cat {
position: absolute;
top: 0;
right: 2px;
width: 40px;
height: 36px;
}