@windcoder/astro-theme-orchid 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,265 @@
1
+ ---
2
+ /**
3
+ * 侧栏音乐 widget(§4.100):与 MusicPlayer(悬浮播放器)共用 config.music 歌单,
4
+ * 经 CustomEvent 联动——本组件点击发 orchid:music-cmd,播放器广播 orchid:music-state。
5
+ * 播放内核仍在 MusicPlayer(transition:persist 跨页常驻),本组件纯遥控 + 状态展示。
6
+ * 首载时序:播放器于 DOMContentLoaded 初始化,本组件在 astro:page-load 初始化
7
+ * (晚于播放器),初始化后发 sync 补拉当前状态。
8
+ */
9
+ import config from "virtual:theme/config";
10
+ import { t } from "../i18n/index.js";
11
+ import { resolveSiteImage } from "../utils/image.js";
12
+
13
+ interface Props {
14
+ /** 移动端(<768px)隐藏(widgets 数组项 hideOnMobile) */
15
+ hideMobile?: boolean;
16
+ }
17
+
18
+ const { hideMobile = false } = Astro.props;
19
+
20
+ // 封面三格式解析(§4.97):构建期解析整份歌单,data-playlist 与 SSR 首曲共用
21
+ const playlist = await Promise.all(
22
+ config.music.playlist.map(
23
+ async (tr: { name: string; artist: string; cover?: string }) => ({
24
+ name: tr.name,
25
+ artist: tr.artist,
26
+ cover: tr.cover ? await resolveSiteImage(tr.cover) : "",
27
+ }),
28
+ ),
29
+ );
30
+
31
+ const labels = {
32
+ play: t("musicPlay"),
33
+ pause: t("musicPause"),
34
+ prev: t("musicPrev"),
35
+ next: t("musicNext"),
36
+ };
37
+ ---
38
+
39
+ <section
40
+ class:list={["side-card", "card", "reveal", "music-widget", { "side-hide-m": hideMobile }]}
41
+ id="orchid-music-widget"
42
+ data-playlist={JSON.stringify(playlist)}
43
+ data-labels={JSON.stringify(labels)}
44
+ >
45
+ <h3>{t("musicWidget")}</h3>
46
+ <div class="mw-body">
47
+ <div class="mw-cover">
48
+ {playlist[0]?.cover ? (
49
+ <img class="mw-cover-img" src={playlist[0].cover} alt="" loading="lazy" />
50
+ ) : (
51
+ <svg class="mw-cover-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
52
+ )}
53
+ </div>
54
+ <div class="mw-meta">
55
+ <span class="mw-name">{playlist[0]?.name ?? ""}</span>
56
+ <span class="mw-artist">{playlist[0]?.artist ?? ""}</span>
57
+ </div>
58
+ <div class="mw-controls">
59
+ <button class="mw-btn mw-prev" type="button" aria-label={labels.prev} title={labels.prev}>
60
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 8 12l10 6V6Z" fill="currentColor" stroke="none"/><path d="M6 5v14"/></svg>
61
+ </button>
62
+ <button class="mw-btn mw-play" type="button" aria-label={labels.play} title={labels.play}>
63
+ <svg class="ic-play" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13l11-6.5-11-6.5Z"/></svg>
64
+ <svg class="ic-pause" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6.5" y="5" width="3.5" height="14" rx="1"/><rect x="14" y="5" width="3.5" height="14" rx="1"/></svg>
65
+ </button>
66
+ <button class="mw-btn mw-next" type="button" aria-label={labels.next} title={labels.next}>
67
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 6 10 6L6 18V6Z" fill="currentColor" stroke="none"/><path d="M18 5v14"/></svg>
68
+ </button>
69
+ </div>
70
+ </div>
71
+ </section>
72
+
73
+ <style>
74
+ .mw-body {
75
+ display: flex;
76
+ align-items: center;
77
+ gap: 12px;
78
+ }
79
+ .mw-cover {
80
+ width: 52px;
81
+ height: 52px;
82
+ flex: none;
83
+ border-radius: 50%;
84
+ overflow: hidden;
85
+ display: grid;
86
+ place-items: center;
87
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
88
+ color: var(--accent);
89
+ }
90
+ .mw-cover img {
91
+ width: 100%;
92
+ height: 100%;
93
+ object-fit: cover;
94
+ }
95
+ .mw-cover svg {
96
+ width: 22px;
97
+ height: 22px;
98
+ }
99
+ .music-widget.playing .mw-cover img {
100
+ animation: mw-spin 12s linear infinite;
101
+ }
102
+ @keyframes mw-spin {
103
+ to {
104
+ transform: rotate(360deg);
105
+ }
106
+ }
107
+ .mw-meta {
108
+ flex: 1;
109
+ min-width: 0;
110
+ display: flex;
111
+ flex-direction: column;
112
+ gap: 2px;
113
+ }
114
+ .mw-name {
115
+ font-size: 14px;
116
+ font-weight: 600;
117
+ white-space: nowrap;
118
+ overflow: hidden;
119
+ text-overflow: ellipsis;
120
+ }
121
+ .mw-artist {
122
+ font-size: 12px;
123
+ color: var(--muted);
124
+ white-space: nowrap;
125
+ overflow: hidden;
126
+ text-overflow: ellipsis;
127
+ }
128
+ .mw-controls {
129
+ display: flex;
130
+ align-items: center;
131
+ gap: 2px;
132
+ flex: none;
133
+ }
134
+ .mw-btn {
135
+ display: grid;
136
+ place-items: center;
137
+ width: 28px;
138
+ height: 28px;
139
+ padding: 0;
140
+ border: none;
141
+ border-radius: 50%;
142
+ background: transparent;
143
+ color: inherit;
144
+ cursor: pointer;
145
+ }
146
+ .mw-btn:hover {
147
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
148
+ color: var(--accent);
149
+ }
150
+ .mw-btn svg {
151
+ width: 16px;
152
+ height: 16px;
153
+ }
154
+ .mw-play svg {
155
+ width: 18px;
156
+ height: 18px;
157
+ }
158
+ /* 播放/暂停图标切换:playing class 由客户端 applyState 维护 */
159
+ .mw-play .ic-pause {
160
+ display: none;
161
+ }
162
+ .music-widget.playing .mw-play .ic-play {
163
+ display: none;
164
+ }
165
+ .music-widget.playing .mw-play .ic-pause {
166
+ display: block;
167
+ }
168
+ </style>
169
+
170
+ <script>
171
+ // 模块级单例:状态缓存跨 VT 换页保留;document 级 state 监听只绑一次
172
+ // (widget DOM 随页面重建,每次 page-load 重查 DOM 重绑按钮),避免监听器累积
173
+ type MusicState = { idx: number; playing: boolean };
174
+ let state: MusicState = { idx: 0, playing: false };
175
+ let docBound = false;
176
+
177
+ function sendCmd(detail: Record<string, unknown>) {
178
+ document.dispatchEvent(new CustomEvent("orchid:music-cmd", { detail }));
179
+ }
180
+
181
+ // 把当前状态应用到本页 widget DOM(换页后由 init 重新调用)
182
+ function applyState() {
183
+ const root = document.getElementById("orchid-music-widget");
184
+ if (!root) return;
185
+ let playlist: { name: string; artist: string; cover: string }[] = [];
186
+ try {
187
+ playlist = JSON.parse(root.dataset.playlist || "[]");
188
+ } catch {
189
+ /* 歌单数据异常时保留 SSR 首曲内容 */
190
+ }
191
+ const tr = playlist[state.idx];
192
+ if (tr) {
193
+ const nameEl = root.querySelector(".mw-name");
194
+ const artistEl = root.querySelector(".mw-artist");
195
+ if (nameEl) nameEl.textContent = tr.name;
196
+ if (artistEl) artistEl.textContent = tr.artist;
197
+ let coverImg = root.querySelector<HTMLImageElement>(".mw-cover-img");
198
+ const coverSvg = root.querySelector(".mw-cover-svg");
199
+ // SSR 只渲染了首曲封面:当前曲有封面而 DOM 里是图标时动态补 img
200
+ if (tr.cover && !coverImg && coverSvg) {
201
+ const img = document.createElement("img");
202
+ img.className = "mw-cover-img";
203
+ img.alt = "";
204
+ coverSvg.replaceWith(img);
205
+ coverImg = img;
206
+ }
207
+ if (coverImg instanceof HTMLImageElement) {
208
+ if (tr.cover) coverImg.src = tr.cover;
209
+ coverImg.toggleAttribute("hidden", !tr.cover);
210
+ }
211
+ coverSvg?.toggleAttribute("hidden", !!tr.cover);
212
+ }
213
+ root.classList.toggle("playing", state.playing);
214
+ let labels: Record<string, string> = {};
215
+ try {
216
+ labels = JSON.parse(root.dataset.labels || "{}");
217
+ } catch {
218
+ /* labels 异常时沿用 SSR aria */
219
+ }
220
+ const playBtn = root.querySelector<HTMLElement>(".mw-play");
221
+ if (playBtn) {
222
+ const label = state.playing ? labels.pause : labels.play;
223
+ if (label) {
224
+ playBtn.setAttribute("aria-label", label);
225
+ playBtn.title = label;
226
+ }
227
+ }
228
+ }
229
+
230
+ function initMusicWidget() {
231
+ const root = document.getElementById("orchid-music-widget");
232
+ if (!root) return;
233
+
234
+ // document 级监听仅绑一次;按钮随 DOM 重建每次绑定
235
+ if (!docBound) {
236
+ docBound = true;
237
+ document.addEventListener("orchid:music-state", (e) => {
238
+ state = (e as CustomEvent<MusicState>).detail;
239
+ applyState();
240
+ });
241
+ }
242
+
243
+ root
244
+ .querySelector(".mw-play")
245
+ ?.addEventListener("click", () => sendCmd({ type: "toggle" }));
246
+ root
247
+ .querySelector(".mw-next")
248
+ ?.addEventListener("click", () => sendCmd({ type: "next" }));
249
+ root
250
+ .querySelector(".mw-prev")
251
+ ?.addEventListener("click", () => sendCmd({ type: "prev" }));
252
+ // 点封面/曲名区 = 播放当前 widget 正显示的曲目
253
+ root.querySelector(".mw-body")?.addEventListener("click", (e) => {
254
+ if ((e.target as HTMLElement).closest(".mw-btn")) return;
255
+ sendCmd({ type: "playIndex", index: state.idx });
256
+ });
257
+
258
+ // 先应用缓存状态再向播放器补拉(播放器 DOM persist 常驻,先于本组件就绪)
259
+ applyState();
260
+ sendCmd({ type: "sync" });
261
+ }
262
+
263
+ // ClientRouter 下 astro:page-load 首载与每次导航都会触发
264
+ document.addEventListener("astro:page-load", initMusicWidget);
265
+ </script>
@@ -6,6 +6,7 @@
6
6
  * showDecrypted → initLightbox 重预置生效)。
7
7
  */
8
8
  import { getLqipStyle } from "../utils/lqip.js";
9
+ import { getThumbInfo } from "../utils/thumb.js";
9
10
 
10
11
  interface Props {
11
12
  src: string;
@@ -13,10 +14,21 @@ interface Props {
13
14
  }
14
15
 
15
16
  const { src, alt = "" } = Astro.props;
17
+ /* §4.106:列表瀑布流用 480 宽缩略图,data-full-* 供灯箱打开原图(原图宽高) */
18
+ const thumb = getThumbInfo(src);
16
19
  ---
17
20
 
18
21
  <figure class="gallery-photo" data-lightbox-item>
19
22
  {/* LQIP 占位渐变(b9.5):src 支持外链,getLqipStyle 查不到时降级纯色 */}
20
23
  <div class="qy-lqip" style={getLqipStyle(src)} aria-hidden="true"></div>
21
- <img src={src} alt={alt} loading="lazy" decoding="async" data-lqip />
24
+ <img
25
+ src={thumb.src}
26
+ alt={alt}
27
+ loading="lazy"
28
+ decoding="async"
29
+ data-lqip
30
+ data-full-src={thumb.full}
31
+ data-full-width={thumb.width}
32
+ data-full-height={thumb.height}
33
+ />
22
34
  </figure>
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  import config from "virtual:theme/config";
3
3
  import { t } from "../i18n/index.js";
4
+ import { resolveSiteImage } from "../utils/image.js";
4
5
  import { getLqipStyle } from "../utils/lqip.js";
6
+ import { getThumbInfo } from "../utils/thumb.js";
5
7
  import { formatDate, type PostLike, resolveCover } from "../utils.js";
6
8
 
7
9
  interface Props {
@@ -24,7 +26,12 @@ const { cover: rawCover, retries } = resolveCover(
24
26
  config.postList.randomCover.apis,
25
27
  post.id,
26
28
  );
27
- const cover = config.postList.showCover ? rawCover : null;
29
+ /* §4.110:封面三格式统一语义(对齐 §4.108 其余消费点)——相对路径(站点 src/
30
+ 下)构建期解析为产物 URL,public / 外链原样直通 */
31
+ const resolvedCover = rawCover ? await resolveSiteImage(rawCover) : rawCover;
32
+ const cover = config.postList.showCover ? resolvedCover : null;
33
+ /* §4.106:列表封面用缩略图(外链/未命中自动回退原图;点击进文章页看正文原图) */
34
+ const thumb = cover ? getThumbInfo(cover) : null;
28
35
  const classes = [
29
36
  "post-card",
30
37
  featured ? "featured" : "",
@@ -47,7 +54,7 @@ const excerpt = d.excerpt || d.description;
47
54
  {/* LQIP 占位渐变(b9.5):绝对定位盖在 img 上,加载完成后由 app.ts 淡出 */}
48
55
  <div class="qy-lqip" style={getLqipStyle(cover)} aria-hidden="true"></div>
49
56
  <img
50
- src={cover}
57
+ src={thumb ? thumb.src : cover}
51
58
  alt={d.title}
52
59
  loading="lazy"
53
60
  data-lqip
@@ -1,19 +1,39 @@
1
1
  ---
2
2
  /**
3
- * 樱花飘落特效(§4.30):全站 canvas 粒子层,程序化贝塞尔花瓣(零贴图资产)。
3
+ * 樱花飘落特效(§4.30 / §4.101 八维参数对齐 Firefly):全站 canvas 粒子层,
4
+ * 程序化贝塞尔花瓣(零贴图资产)。
4
5
  * - prefers-reduced-motion 自动停用
5
6
  * - 页签隐藏暂停、可见时恢复
7
+ * - limitTimes 控制飘落轮数(-1 无限);配额耗尽后不再重生,花瓣落尽即停帧
6
8
  * - VT swap 后 body 重建,经 astro:page-load 重新初始化(模块级单例清理旧动画)
7
9
  */
8
10
  interface Props {
9
- /** 同屏花瓣数(schema sakura.density) */
10
- density?: number;
11
+ /** 完整 sakura 配置(schema 已补全默认值),经 data-cfg JSON 传给客户端 */
12
+ cfg?: {
13
+ enabled?: boolean;
14
+ density: number;
15
+ limitTimes: number;
16
+ zIndex: number;
17
+ size: { min: number; max: number };
18
+ opacity: { min: number; max: number };
19
+ speed: {
20
+ horizontal: { min: number; max: number };
21
+ vertical: { min: number; max: number };
22
+ rotation: number;
23
+ fadeSpeed: number;
24
+ };
25
+ };
11
26
  }
12
27
 
13
- const { density = 24 } = Astro.props;
28
+ const cfg = Astro.props.cfg;
14
29
  ---
15
30
 
16
- <canvas id="sakura-canvas" data-density={density} aria-hidden="true"></canvas>
31
+ <canvas
32
+ id="sakura-canvas"
33
+ data-cfg={JSON.stringify(cfg)}
34
+ style={`z-index:${cfg?.zIndex ?? 100}`}
35
+ aria-hidden="true"
36
+ ></canvas>
17
37
 
18
38
  <style>
19
39
  #sakura-canvas {
@@ -22,7 +42,6 @@ const { density = 24 } = Astro.props;
22
42
  width: 100%;
23
43
  height: 100%;
24
44
  pointer-events: none;
25
- z-index: 90;
26
45
  }
27
46
  </style>
28
47
 
@@ -39,11 +58,41 @@ const { density = 24 } = Astro.props;
39
58
  ) as HTMLCanvasElement | null;
40
59
  if (!canvas) return;
41
60
  if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
61
+ /* §4.103:显示设置面板关闭樱花(html[data-wp-sakura="off"])时不启动 */
62
+ if (document.documentElement.dataset.wpSakura === "off") return;
42
63
 
43
64
  const ctx = canvas.getContext("2d");
44
65
  if (!ctx) return;
45
66
 
46
- const count = Number(canvas.dataset.density) || 24;
67
+ // 八维参数(§4.101):缺省回退与 schema 默认一致(防 data-cfg 解析失败)
68
+ let cfg: {
69
+ density?: number;
70
+ limitTimes?: number;
71
+ size?: { min: number; max: number };
72
+ opacity?: { min: number; max: number };
73
+ speed?: {
74
+ horizontal: { min: number; max: number };
75
+ vertical: { min: number; max: number };
76
+ rotation: number;
77
+ fadeSpeed: number;
78
+ };
79
+ };
80
+ try {
81
+ cfg = JSON.parse(canvas.dataset.cfg || "{}");
82
+ } catch {
83
+ cfg = {};
84
+ }
85
+ const count = Math.max(1, Number(cfg.density) || 24);
86
+ const sizeR = cfg.size ?? { min: 0.5, max: 1.1 };
87
+ const opaR = cfg.opacity ?? { min: 0.3, max: 0.9 };
88
+ const spd = cfg.speed ?? {
89
+ horizontal: { min: -1.7, max: -1.2 },
90
+ vertical: { min: 1.5, max: 2.2 },
91
+ rotation: 0.03,
92
+ fadeSpeed: 0.03,
93
+ };
94
+ const rand = (min: number, max: number) => min + Math.random() * (max - min);
95
+
47
96
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
48
97
 
49
98
  let w = 0;
@@ -60,19 +109,24 @@ const { density = 24 } = Astro.props;
60
109
 
61
110
  // 3 档粉调色板(对齐参考站观感)
62
111
  const colors = ["#ffd7e2", "#ffc0d0", "#fdaec5"];
112
+ // 花瓣基准尺寸(px):size 配置为其倍数区间
113
+ const BASE_SIZE = 14;
63
114
 
64
115
  interface Petal {
65
116
  x: number;
66
117
  y: number;
67
118
  size: number;
68
- speedY: number;
69
- swayAmp: number;
70
- swayFreq: number;
71
- phase: number;
119
+ /** 水平漂移速度(负值向左,§4.101 speed.horizontal) */
120
+ vx: number;
121
+ /** 下落速度(§4.101 speed.vertical) */
122
+ vy: number;
72
123
  rot: number;
73
- rotSpeed: number;
74
124
  color: string;
75
- opacity: number;
125
+ alpha: number;
126
+ /** 生命帧数:超过后进入临终淡出(fadeSpeed),alpha 耗尽即重生 */
127
+ life: number;
128
+ age: number;
129
+ dead: boolean;
76
130
  }
77
131
 
78
132
  const spawn = (randomY = false): Petal => {
@@ -80,18 +134,25 @@ const { density = 24 } = Astro.props;
80
134
  return {
81
135
  x: Math.random() * w,
82
136
  y: randomY ? Math.random() * h : -20,
83
- size: 6 + Math.random() * 8,
84
- speedY: 0.6 + Math.random() * 1.2,
85
- swayAmp: 20 + Math.random() * 40,
86
- swayFreq: 0.005 + Math.random() * 0.01,
87
- phase: Math.random() * Math.PI * 2,
137
+ size: BASE_SIZE * rand(sizeR.min, sizeR.max),
138
+ vx: rand(spd.horizontal.min, spd.horizontal.max),
139
+ vy: rand(spd.vertical.min, spd.vertical.max),
88
140
  rot: Math.random() * Math.PI * 2,
89
- rotSpeed: (Math.random() - 0.5) * 0.03,
90
141
  color: colors[i] ?? colors[0]!,
91
- opacity: 0.5 + Math.random() * 0.4,
142
+ alpha: rand(opaR.min, opaR.max),
143
+ life: 480 + Math.random() * 720,
144
+ age: 0,
145
+ dead: false,
92
146
  };
93
147
  };
94
148
 
149
+ // limitTimes 飘落轮数(§4.101):-1 无限;>=1 时总重生配额 = 轮数 × 花瓣数,
150
+ // 耗尽后不再重生,花瓣落尽即停止动画
151
+ let quota =
152
+ cfg.limitTimes === undefined || cfg.limitTimes < 0
153
+ ? Number.POSITIVE_INFINITY
154
+ : cfg.limitTimes * count;
155
+
95
156
  const petals: Petal[] = Array.from({ length: count }, () => spawn(true));
96
157
 
97
158
  // 程序化花瓣:两段贝塞尔拼出泪滴形
@@ -100,7 +161,7 @@ const { density = 24 } = Astro.props;
100
161
  ctx.save();
101
162
  ctx.translate(p.x, p.y);
102
163
  ctx.rotate(p.rot);
103
- ctx.globalAlpha = p.opacity;
164
+ ctx.globalAlpha = p.alpha;
104
165
  ctx.fillStyle = p.color;
105
166
  ctx.beginPath();
106
167
  ctx.moveTo(0, -s);
@@ -110,19 +171,40 @@ const { density = 24 } = Astro.props;
110
171
  ctx.restore();
111
172
  };
112
173
 
174
+ // 重生:消耗配额;配额耗尽标记 dead(不再绘制)
175
+ const respawn = (p: Petal) => {
176
+ if (quota > 0) {
177
+ quota--;
178
+ Object.assign(p, spawn());
179
+ } else {
180
+ p.dead = true;
181
+ }
182
+ };
183
+
113
184
  let paused = false;
114
- let frame = 0;
115
185
  const tick = () => {
116
186
  if (paused) return;
117
- frame++;
118
187
  ctx.clearRect(0, 0, w, h);
188
+ let alive = 0;
119
189
  for (const p of petals) {
120
- p.y += p.speedY;
121
- p.x += Math.sin(frame * p.swayFreq + p.phase) * (p.swayAmp / 60);
122
- p.rot += p.rotSpeed;
123
- if (p.y > h + 30) Object.assign(p, spawn());
124
- drawPetal(p);
190
+ if (p.dead) continue;
191
+ alive++;
192
+ p.age++;
193
+ // 临终淡出(fadeSpeed):寿命耗尽后 alpha 递减至 0 重生
194
+ if (p.age > p.life) {
195
+ p.alpha -= spd.fadeSpeed;
196
+ if (p.alpha <= 0) {
197
+ respawn(p);
198
+ continue;
199
+ }
200
+ }
201
+ p.y += p.vy;
202
+ p.x += p.vx;
203
+ p.rot += spd.rotation;
204
+ if (p.y > h + 30 || p.x < -40 || p.x > w + 40) respawn(p);
205
+ else drawPetal(p);
125
206
  }
207
+ if (alive === 0) return; // 配额耗尽且花瓣落尽:停止循环
126
208
  raf = requestAnimationFrame(tick);
127
209
  };
128
210
 
@@ -140,6 +222,7 @@ const { density = 24 } = Astro.props;
140
222
  raf = requestAnimationFrame(tick);
141
223
 
142
224
  cleanup = () => {
225
+ paused = true;
143
226
  cancelAnimationFrame(raf);
144
227
  window.removeEventListener("resize", resize);
145
228
  document.removeEventListener("visibilitychange", onVisibility);
@@ -149,4 +232,19 @@ const { density = 24 } = Astro.props;
149
232
 
150
233
  // ClientRouter 下 astro:page-load 首载与每次导航都会触发
151
234
  document.addEventListener("astro:page-load", startSakura);
235
+
236
+ /* §4.103:显示设置面板樱花开关(app.ts applyDsSwitch 派发);
237
+ off 立即停动画并清画布,on 重启(startSakura 内部先清理旧实例) */
238
+ document.addEventListener("orchid:sakura-toggle", (e) => {
239
+ const on = (e as CustomEvent).detail !== "off";
240
+ if (on) {
241
+ startSakura();
242
+ } else {
243
+ cleanup?.();
244
+ const canvas = document.getElementById(
245
+ "sakura-canvas",
246
+ ) as HTMLCanvasElement | null;
247
+ canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height);
248
+ }
249
+ });
152
250
  </script>