dsh-theme-gallery 0.4.1 → 0.6.0

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.
package/lib/client.js CHANGED
@@ -82,6 +82,9 @@ window.__ModuleLoader__.load({
82
82
  // module seed (`dsh-client-store`) or the shell (`react/jsx-runtime`).
83
83
  const { defineStore } = require('@deepseek-ai/dsh-client-store')
84
84
  const { jsx, jsxs } = require('react/jsx-runtime')
85
+ // 设置分节组件用:把 pinned store 订阅成 React 视图(useSyncExternalStore,
86
+ // React 18+;dsh-pet 的分节同样直接消费 react)。本插件其余部分仍零 hooks。
87
+ const { useSyncExternalStore } = require('react')
85
88
 
86
89
  /** Panel id shared by the sidebar icon and the main-column page. */
87
90
  const PANEL_ID = 'theme-gallery'
@@ -146,6 +149,9 @@ window.__ModuleLoader__.load({
146
149
  'meng-hai-you-yu': 80,
147
150
  'hu-po-mao-mi': 76,
148
151
  'hu-zi-a-huang': 75,
152
+ 'pet-family': 74,
153
+ 'mood-greeting': 73,
154
+ 'menu-collapse': 72,
149
155
  }
150
156
 
151
157
  /**
@@ -215,6 +221,45 @@ window.__ModuleLoader__.load({
215
221
  /** 所选配色方案记在这里。与皮肤 id 一样,**绝不**写进 `ui-theme.preference`。 */
216
222
  const SCHEME_KEY = 'theme-gallery:palette'
217
223
 
224
+ /* ═══════════════════ 宠物挂件卡(宠物家族 × 7)═══════════════════
225
+ *
226
+ * 这张卡**不是主题**:它不进 `lib/themes/`、不被 `contribute()` 注册进主题服务、
227
+ * 官方「设置 → 通用 → 外观」不会多出这一项,点它也**不切主题** —— 只开关/挑选挂件。
228
+ * 因此它天然能与任何皮肤(包括山青婷彩 / 梦海游鱼)同时开启,也不消耗主题写入预算。
229
+ * 卡片数据只在面板里合成(见 `petWidgetCardTheme`),挂件本体画在
230
+ * `#dsh-theme-pet` 固定层,锚定对话输入框 —— 不配 `ambient`,不碰侧栏素材区。
231
+ * 二期:卡内是 7 只宠物的头像选择行(`petPickerElement`),点头像换宠物并放它出场。
232
+ */
233
+
234
+ /**
235
+ * 挂件卡的 id、标题与 tooltip 文案。
236
+ *
237
+ * 卡片正面**不渲染** description(与「纯色/拼色」卡同一条分支,见 `ThemeCard`):
238
+ * 卡面是标题、状态徽标、当前宠物与它的道具图案,再加一行头像选择,这段文案只作
239
+ * 悬停提示(title)。schema 对主题 JSON 的 description 非空约束管不到它 —— 它
240
+ * 本来就不是主题 JSON。
241
+ */
242
+ const PET_WIDGET = {
243
+ id: 'pet-family',
244
+ label: '宠物挂件 · 七只小伙伴',
245
+ description: '七只小宠物轮流陪聊:点头像换一只并放它出场,点卡身空白处仅开关。'
246
+ + '挂件不改变当前主题皮肤;移到宠物身上可改名、戳一戳;拖动宠物或它的道具,'
247
+ + '它会跑向道具。它还会在你想事情时说悄悄话、在模型生成时为你加油。',
248
+ }
249
+
250
+ /** 挂件的启停 / 名字 / 位置记在这里。与皮肤 id 同一条铁律:绝不写 `ui-theme.preference`。 */
251
+ const PET_KEY = 'theme-gallery:pet'
252
+
253
+ /**
254
+ * 宠物的显示缩放。注册表 `size` 是逻辑坐标(与 viewBox 1:1,全部几何以它推),
255
+ * 实机验收反馈整体偏小 —— 屏幕显示统一放大 15%,宠物与道具同缩放。
256
+ * 放大只发生在**消费端**(petDisplaySize / petPropDisplaySize 两个助手):
257
+ * 注册表数据与 viewBox 一个数字都不动(check-pet-registry 的 P3 钉的就是 1:1)。
258
+ * 声明在这里(而非宠物运行区)是因为面板卡 petCardSceneHTML 也要读它 ——
259
+ * TDZ 审计要求 const 声明在第一个读取者之前。
260
+ */
261
+ const PET_SCALE = 1.15
262
+
218
263
  /**
219
264
  * 按 id 取一套配色方案。
220
265
  * @param id - 方案 id。
@@ -599,7 +644,7 @@ window.__ModuleLoader__.load({
599
644
  * package.json, and the release workflow fails when re-running the embed step
600
645
  * changes a tracked file.
601
646
  */
602
- const BUNDLED_VERSION = '0.4.1'
647
+ const BUNDLED_VERSION = '0.6.0'
603
648
 
604
649
  /**
605
650
  * Themes this package contributes, inlined from lib/themes/*.json.
@@ -2835,6 +2880,419 @@ window.__ModuleLoader__.load({
2835
2880
  "source": "蓝 8-2 藏青(深蓝)+ 金银 8-0 赤金 + 精白 + 红 0-0 粉红 + 绿 2-14 翡翠色"
2836
2881
  }
2837
2882
  ]
2883
+
2884
+ const BUNDLED_MOOD_LINES = {
2885
+ "$comment": "心情问候语库(标题栏跑马灯)。出处:40 条种子复制自作者自己的「更新管理平台」站点(装修管理-心情问候语,2026-09-27 抓取,seq 1-40);seq 101+ 为插件新增。分类沿用站点五类:morning 早安 / day 白天 / night 夜深 / general 通用 / holiday 节假日。节日区间为 MM-DD(去年份,每年复用)。红线:禁止负面、消极或敏感言论——embed-themes.mjs 建期黑名单校验,命中即构建失败。单条 text 不超过 100 字符。",
2886
+ "version": 1,
2887
+ "lines": [
2888
+ {
2889
+ "id": "m001",
2890
+ "seq": 1,
2891
+ "category": "morning",
2892
+ "text": "寻必寻见,想必到达"
2893
+ },
2894
+ {
2895
+ "id": "m002",
2896
+ "seq": 2,
2897
+ "category": "morning",
2898
+ "text": "晨光不负赶路人,新的一天从心安开始"
2899
+ },
2900
+ {
2901
+ "id": "m003",
2902
+ "seq": 3,
2903
+ "category": "morning",
2904
+ "text": "今天也要做自己的太阳,无需凭借谁的光"
2905
+ },
2906
+ {
2907
+ "id": "m004",
2908
+ "seq": 4,
2909
+ "category": "morning",
2910
+ "text": "万物皆有裂痕,那是光照进来的地方"
2911
+ },
2912
+ {
2913
+ "id": "m005",
2914
+ "seq": 5,
2915
+ "category": "morning",
2916
+ "text": "早安,愿你所遇皆温柔,所行皆坦途"
2917
+ },
2918
+ {
2919
+ "id": "m006",
2920
+ "seq": 6,
2921
+ "category": "morning",
2922
+ "text": "把昨天的疲惫留在梦里,把今天的期待装进行囊"
2923
+ },
2924
+ {
2925
+ "id": "m007",
2926
+ "seq": 7,
2927
+ "category": "morning",
2928
+ "text": "山高路远,看世界,也找自己"
2929
+ },
2930
+ {
2931
+ "id": "m008",
2932
+ "seq": 8,
2933
+ "category": "morning",
2934
+ "text": "每一个清晨,都是世界温柔的重新开始"
2935
+ },
2936
+ {
2937
+ "id": "m009",
2938
+ "seq": 9,
2939
+ "category": "day",
2940
+ "text": "你对我来说真的很重要"
2941
+ },
2942
+ {
2943
+ "id": "m010",
2944
+ "seq": 10,
2945
+ "category": "day",
2946
+ "text": "心若无所求,有风无风皆自由"
2947
+ },
2948
+ {
2949
+ "id": "m011",
2950
+ "seq": 11,
2951
+ "category": "day",
2952
+ "text": "慢慢来,比较快"
2953
+ },
2954
+ {
2955
+ "id": "m012",
2956
+ "seq": 12,
2957
+ "category": "day",
2958
+ "text": "生活的答案,都藏在认真过好的今天里"
2959
+ },
2960
+ {
2961
+ "id": "m013",
2962
+ "seq": 13,
2963
+ "category": "day",
2964
+ "text": "保持热爱,奔赴山海"
2965
+ },
2966
+ {
2967
+ "id": "m014",
2968
+ "seq": 14,
2969
+ "category": "day",
2970
+ "text": "心中有丘壑,眉目作山河"
2971
+ },
2972
+ {
2973
+ "id": "m015",
2974
+ "seq": 15,
2975
+ "category": "day",
2976
+ "text": "世界盛大,欢迎回家"
2977
+ },
2978
+ {
2979
+ "id": "m016",
2980
+ "seq": 16,
2981
+ "category": "day",
2982
+ "text": "别慌,月亮也正在大海的某处迷茫"
2983
+ },
2984
+ {
2985
+ "id": "m017",
2986
+ "seq": 17,
2987
+ "category": "day",
2988
+ "text": "与其追风去,不如等风来;等风来,不如乘风起"
2989
+ },
2990
+ {
2991
+ "id": "m018",
2992
+ "seq": 18,
2993
+ "category": "day",
2994
+ "text": "认真生活的人,生活从不亏待"
2995
+ },
2996
+ {
2997
+ "id": "m019",
2998
+ "seq": 19,
2999
+ "category": "night",
3000
+ "text": "我来到你待过的星空,算不算相逢"
3001
+ },
3002
+ {
3003
+ "id": "m020",
3004
+ "seq": 20,
3005
+ "category": "night",
3006
+ "text": "夜色温柔,愿你好梦"
3007
+ },
3008
+ {
3009
+ "id": "m021",
3010
+ "seq": 21,
3011
+ "category": "night",
3012
+ "text": "晚风吹人醒,万事藏于心"
3013
+ },
3014
+ {
3015
+ "id": "m022",
3016
+ "seq": 22,
3017
+ "category": "night",
3018
+ "text": "星光不问赶路人,时光不负有心人"
3019
+ },
3020
+ {
3021
+ "id": "m023",
3022
+ "seq": 23,
3023
+ "category": "night",
3024
+ "text": "今晚的月色替我说晚安"
3025
+ },
3026
+ {
3027
+ "id": "m024",
3028
+ "seq": 24,
3029
+ "category": "night",
3030
+ "text": "熬过漫长黑夜,才配拥有满天星辰"
3031
+ },
3032
+ {
3033
+ "id": "m025",
3034
+ "seq": 25,
3035
+ "category": "night",
3036
+ "text": "愿所有的疲惫,都被这个夜晚温柔收留"
3037
+ },
3038
+ {
3039
+ "id": "m026",
3040
+ "seq": 26,
3041
+ "category": "night",
3042
+ "text": "深夜的坚持,是明天惊喜的伏笔"
3043
+ },
3044
+ {
3045
+ "id": "m027",
3046
+ "seq": 27,
3047
+ "category": "holiday",
3048
+ "text": "盛世华诞,与国同庆;山河远阔,人间烟火",
3049
+ "holiday": {
3050
+ "from": "10-01",
3051
+ "to": "10-08"
3052
+ }
3053
+ },
3054
+ {
3055
+ "id": "m028",
3056
+ "seq": 28,
3057
+ "category": "holiday",
3058
+ "text": "新年快乐,愿新岁胜旧年,万事尽可期",
3059
+ "holiday": {
3060
+ "from": "01-01",
3061
+ "to": "01-03"
3062
+ }
3063
+ },
3064
+ {
3065
+ "id": "m029",
3066
+ "seq": 29,
3067
+ "category": "holiday",
3068
+ "text": "新春吉祥,灯火可亲,岁岁常安",
3069
+ "holiday": {
3070
+ "from": "02-05",
3071
+ "to": "02-12"
3072
+ }
3073
+ },
3074
+ {
3075
+ "id": "m030",
3076
+ "seq": 30,
3077
+ "category": "holiday",
3078
+ "text": "清明时节,追思故人,也惜眼前春光",
3079
+ "holiday": {
3080
+ "from": "04-04",
3081
+ "to": "04-06"
3082
+ }
3083
+ },
3084
+ {
3085
+ "id": "m031",
3086
+ "seq": 31,
3087
+ "category": "holiday",
3088
+ "text": "劳动节快乐,向每一位认真生活的人致敬",
3089
+ "holiday": {
3090
+ "from": "05-01",
3091
+ "to": "05-05"
3092
+ }
3093
+ },
3094
+ {
3095
+ "id": "m032",
3096
+ "seq": 32,
3097
+ "category": "holiday",
3098
+ "text": "粽叶飘香,端午安康",
3099
+ "holiday": {
3100
+ "from": "06-09",
3101
+ "to": "06-11"
3102
+ }
3103
+ },
3104
+ {
3105
+ "id": "m033",
3106
+ "seq": 33,
3107
+ "category": "holiday",
3108
+ "text": "但愿人长久,千里共婵娟",
3109
+ "holiday": {
3110
+ "from": "09-15",
3111
+ "to": "09-16"
3112
+ }
3113
+ },
3114
+ {
3115
+ "id": "m034",
3116
+ "seq": 34,
3117
+ "category": "holiday",
3118
+ "text": "举国同庆,家和人安,愿岁并谢,与友长兮",
3119
+ "holiday": {
3120
+ "from": "10-01",
3121
+ "to": "10-08"
3122
+ }
3123
+ },
3124
+ {
3125
+ "id": "m035",
3126
+ "seq": 35,
3127
+ "category": "general",
3128
+ "text": "愿你眼中总有光芒,活成你想要的模样"
3129
+ },
3130
+ {
3131
+ "id": "m036",
3132
+ "seq": 36,
3133
+ "category": "general",
3134
+ "text": "万事顺遂,毫无蹉跎"
3135
+ },
3136
+ {
3137
+ "id": "m037",
3138
+ "seq": 37,
3139
+ "category": "general",
3140
+ "text": "所有的美好,都在赶来的路上"
3141
+ },
3142
+ {
3143
+ "id": "m038",
3144
+ "seq": 38,
3145
+ "category": "general",
3146
+ "text": "心里有光,慢一点又何妨"
3147
+ },
3148
+ {
3149
+ "id": "m039",
3150
+ "seq": 39,
3151
+ "category": "general",
3152
+ "text": "平安喜乐,得偿所愿"
3153
+ },
3154
+ {
3155
+ "id": "m040",
3156
+ "seq": 40,
3157
+ "category": "general",
3158
+ "text": "岁月不扰,余生静好"
3159
+ },
3160
+ {
3161
+ "id": "m101",
3162
+ "seq": 101,
3163
+ "category": "morning",
3164
+ "text": "清晨的风,是今天寄来的第一封信"
3165
+ },
3166
+ {
3167
+ "id": "m102",
3168
+ "seq": 102,
3169
+ "category": "morning",
3170
+ "text": "新的一天:把小事做好,把心情调亮"
3171
+ },
3172
+ {
3173
+ "id": "m103",
3174
+ "seq": 103,
3175
+ "category": "morning",
3176
+ "text": "太阳照常升起,你也照常了不起"
3177
+ },
3178
+ {
3179
+ "id": "m104",
3180
+ "seq": 104,
3181
+ "category": "morning",
3182
+ "text": "今天的你,比昨天多一点点勇敢"
3183
+ },
3184
+ {
3185
+ "id": "m105",
3186
+ "seq": 105,
3187
+ "category": "morning",
3188
+ "text": "晨光已就位,慢慢来,别着急"
3189
+ },
3190
+ {
3191
+ "id": "m106",
3192
+ "seq": 106,
3193
+ "category": "day",
3194
+ "text": "慢慢走,路上有花"
3195
+ },
3196
+ {
3197
+ "id": "m107",
3198
+ "seq": 107,
3199
+ "category": "day",
3200
+ "text": "把日子过成自己喜欢的样子"
3201
+ },
3202
+ {
3203
+ "id": "m108",
3204
+ "seq": 108,
3205
+ "category": "day",
3206
+ "text": "今天的风很温柔,很适合认真生活"
3207
+ },
3208
+ {
3209
+ "id": "m109",
3210
+ "seq": 109,
3211
+ "category": "day",
3212
+ "text": "一步一步,都是向上的路"
3213
+ },
3214
+ {
3215
+ "id": "m110",
3216
+ "seq": 110,
3217
+ "category": "day",
3218
+ "text": "保持微笑,好运正在排队"
3219
+ },
3220
+ {
3221
+ "id": "m111",
3222
+ "seq": 111,
3223
+ "category": "night",
3224
+ "text": "夜深了,把烦恼调成静音"
3225
+ },
3226
+ {
3227
+ "id": "m112",
3228
+ "seq": 112,
3229
+ "category": "night",
3230
+ "text": "星星已就位,陪你收工"
3231
+ },
3232
+ {
3233
+ "id": "m113",
3234
+ "seq": 113,
3235
+ "category": "night",
3236
+ "text": "今晚适合早点休息,明天见"
3237
+ },
3238
+ {
3239
+ "id": "m114",
3240
+ "seq": 114,
3241
+ "category": "night",
3242
+ "text": "愿你被月光温柔以待"
3243
+ },
3244
+ {
3245
+ "id": "m115",
3246
+ "seq": 115,
3247
+ "category": "night",
3248
+ "text": "辛苦了,今天也好好完成了"
3249
+ },
3250
+ {
3251
+ "id": "m116",
3252
+ "seq": 116,
3253
+ "category": "general",
3254
+ "text": "慢慢来,一切都会如期而至"
3255
+ },
3256
+ {
3257
+ "id": "m117",
3258
+ "seq": 117,
3259
+ "category": "general",
3260
+ "text": "生活明朗,万物可爱"
3261
+ },
3262
+ {
3263
+ "id": "m118",
3264
+ "seq": 118,
3265
+ "category": "general",
3266
+ "text": "你走的每一步,都算数"
3267
+ },
3268
+ {
3269
+ "id": "m119",
3270
+ "seq": 119,
3271
+ "category": "general",
3272
+ "text": "所求皆如愿,所行化坦途"
3273
+ },
3274
+ {
3275
+ "id": "m120",
3276
+ "seq": 120,
3277
+ "category": "holiday",
3278
+ "text": "致每一位闪闪发光的她,节日快乐",
3279
+ "holiday": {
3280
+ "from": "03-08",
3281
+ "to": "03-08"
3282
+ }
3283
+ },
3284
+ {
3285
+ "id": "m121",
3286
+ "seq": 121,
3287
+ "category": "holiday",
3288
+ "text": "愿你我童心未泯,简单快乐",
3289
+ "holiday": {
3290
+ "from": "06-01",
3291
+ "to": "06-01"
3292
+ }
3293
+ }
3294
+ ]
3295
+ }
2838
3296
  PALETTE_SCHEMES = Array.isArray(BUNDLED_PALETTES) ? BUNDLED_PALETTES : []
2839
3297
 
2840
3298
  /**
@@ -3195,9 +3653,20 @@ window.__ModuleLoader__.load({
3195
3653
  const zh = {
3196
3654
  title: '主题皮肤',
3197
3655
  hint: '点一下即切换,选中会记入设置',
3198
- count: '{count} 款皮肤(另有内置浅色/深色两张卡)',
3656
+ count: '{count} 款皮肤(另有内置浅色/深色、宠物挂件、心情问候与菜单折叠五张卡)',
3199
3657
  current: '当前',
3200
3658
  applied: '已应用',
3659
+ petOn: '已开启',
3660
+ petOff: '已关闭',
3661
+ menuCollapsed: '已折叠',
3662
+ menuShown: '侧栏显示中',
3663
+ menuPillLabel: '侧栏入口',
3664
+ menuHint: '开启后,左侧栏不再显示「主题皮肤」入口;设置 → 通用设置(agent预设 下方)里有轻量入口,可一键打开面板或恢复侧栏显示。默认关闭。',
3665
+ sectionHint: '换皮肤、调挂件与问候语都在主区全屏面板完成,效果看得见;这里是一键入口。',
3666
+ openPanel: '打开主题皮肤面板',
3667
+ restoreSidebar: '恢复侧栏入口',
3668
+ sectionSkinLabel: '当前皮肤:',
3669
+ sectionSidebarLabel: '侧栏入口:',
3201
3670
  empty: '正在读取官方主题注册表…',
3202
3671
  // The diagnostics below the cards are a FEATURE — a live reading of the active skin —
3203
3672
  // and a wall of monospace text reads as a fault unless the panel says otherwise.
@@ -3207,9 +3676,20 @@ window.__ModuleLoader__.load({
3207
3676
  const en = {
3208
3677
  title: 'Theme skins',
3209
3678
  hint: 'Click to switch; the choice is remembered',
3210
- count: '{count} skins (+ built-in light/dark cards)',
3679
+ count: '{count} skins (+ built-in light/dark, pet-widget, mood-greeting and menu-collapse cards)',
3211
3680
  current: 'Current',
3212
3681
  applied: 'Applied',
3682
+ petOn: 'On',
3683
+ petOff: 'Off',
3684
+ menuCollapsed: 'Collapsed',
3685
+ menuShown: 'In sidebar',
3686
+ menuPillLabel: 'Sidebar entry',
3687
+ menuHint: 'When on, the sidebar no longer shows this entry; Settings → General (below the Agent preset) has a lightweight entry to open the panel or restore the sidebar. Off by default.',
3688
+ sectionHint: 'Skins, the pet and the greeting live in the full main-area panel where the effect is visible; this is a one-click entry.',
3689
+ openPanel: 'Open the theme panel',
3690
+ restoreSidebar: 'Show the sidebar entry',
3691
+ sectionSkinLabel: 'Current skin: ',
3692
+ sectionSidebarLabel: 'Sidebar entry: ',
3213
3693
  empty: 'Reading the theme registry…',
3214
3694
  diagNote: 'The lines below are intentional diagnostics of the live skin state, '
3215
3695
  + 'not an error report; only lines starting with ⚠ mean the scenery is not working',
@@ -3261,6 +3741,33 @@ window.__ModuleLoader__.load({
3261
3741
  '.tg-badge{font-size:11px;padding:1px 7px;border-radius:999px;',
3262
3742
  'background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground)}',
3263
3743
  '.tg-desc{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.5}',
3744
+ /* 菜单折叠卡的路径提示(卡面正文):与 .tg-hint(页头)无关,这是卡内元素。 */
3745
+ '.tg-path{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.55}',
3746
+ /* 菜单折叠卡的胶囊行容器:胶囊行(复用 .tg-mood-row 的排布)+ 路径提示上下排。 */
3747
+ '.tg-menu-body{display:flex;flex-direction:column;gap:8px;align-items:stretch}',
3748
+ /* ── the lightweight settings-section entry (form A, user-approved) ───────
3749
+ 设置弹窗里的轻量分节:报状态 + 一键跳转主区面板 + 折叠时给恢复按钮。
3750
+ 容器是弹窗内容列(宽度不由我们定),所以全部用自适应宽度 + 垂直节奏,
3751
+ 不放网格 —— 完整面板在弹窗里既会挤也没意义(方案 A 的结论)。
3752
+ 排版(用户返工 2026-09-27):读数块(label 左值右)+ 主次分明的按钮组
3753
+ (主按钮 = 品牌色整宽跳转,次按钮 = 描边恢复)。 */
3754
+ '.tg-section{display:flex;flex-direction:column;gap:14px;padding:6px 2px 2px}',
3755
+ '.tg-section-title{font-size:15px;font-weight:600}',
3756
+ '.tg-section-hint{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.65}',
3757
+ '.tg-section-facts{display:flex;flex-direction:column;gap:8px;padding:10px 12px;'
3758
+ + 'border:1px solid var(--dsw-alias-border-l3);border-radius:10px}',
3759
+ '.tg-section-row{display:flex;align-items:center;justify-content:space-between;gap:12px;'
3760
+ + 'font-size:12.5px;color:var(--dsw-alias-label-secondary)}',
3761
+ '.tg-section-value{color:var(--dsw-alias-label-primary);font-weight:600;text-align:right}',
3762
+ '.tg-section-actions{display:flex;flex-direction:column;gap:8px}',
3763
+ '.tg-section-open{width:100%;padding:9px 14px;border:none;border-radius:10px;'
3764
+ + 'background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground);'
3765
+ + 'font-size:13px;font-weight:600;cursor:pointer}',
3766
+ '.tg-section-open:hover{filter:brightness(1.08)}',
3767
+ '.tg-section-restore{width:100%;padding:9px 14px;border:1px solid var(--dsw-alias-border-l3);'
3768
+ + 'border-radius:10px;background:transparent;color:var(--dsw-alias-label-primary);'
3769
+ + 'font-size:12.5px;cursor:pointer}',
3770
+ '.tg-section-restore:hover{background:var(--dsw-alias-interactive-bg-hover)}',
3264
3771
  '.tg-strip{display:flex;height:6px;border-radius:999px;overflow:hidden;border:.5px solid var(--dsw-alias-border-l3)}',
3265
3772
  '.tg-strip span{flex:1}',
3266
3773
  /* ── the PALETTE PICKER card (15 clickable colour buttons) ────────────────
@@ -3308,6 +3815,62 @@ window.__ModuleLoader__.load({
3308
3815
  'font-size:11px;line-height:1.3;background:var(--dsw-alias-bg-base);',
3309
3816
  'color:var(--dsw-alias-label-primary);border:.5px solid var(--dsw-alias-border-l3);',
3310
3817
  'box-shadow:0 2px 8px rgba(0,0,0,.18)}',
3818
+ /* ── the PET-FAMILY card (宠物挂件 · 七只小伙伴) ────────────────────────
3819
+ 卡面放标题、状态徽标、当前宠物与道具的图案,再加一行 7 颗头像(`petPickerElement`)
3820
+ —— description 只作 tooltip(与配色卡同一条"不渲染 desc"分支)。卡身是 div
3821
+ (内含头像按钮,button 套 button 非法):点卡身 = 开关挂件,点头像 = 换宠物。 */
3822
+ '.tg-pet-card{justify-content:space-between}',
3823
+ '.tg-pet-body{display:flex;flex-direction:column;gap:6px}',
3824
+ '.tg-pet-art{display:flex;align-items:flex-end;gap:14px;padding:6px 2px 2px;min-height:48px}',
3825
+ '.tg-pet-art svg{display:block}',
3826
+ '.tg-petrow{display:flex;gap:6px;flex-wrap:wrap;padding:2px}',
3827
+ '.tg-petswatch{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;'
3828
+ + 'padding:2px;border:none;border-radius:9px;cursor:pointer;background:var(--dsw-alias-bg-layer-2)}',
3829
+ '.tg-petswatch:hover{background:var(--dsw-alias-bg-layer-3)}',
3830
+ '.tg-petswatch:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}',
3831
+ '.tg-petswatch[aria-pressed="true"]{background:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary) inset}',
3832
+ '.tg-petswatch svg{display:block}',
3833
+ /* 挂件"关着"的徽标:中性色,与亮着的"已应用"/"已开启"品牌徽标区分开。 */
3834
+ '.tg-badge-off{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary)}',
3835
+ /* ── 心情问候卡:胶囊开关 + 三组设置 chips ───────────────────────────
3836
+ 卡身是 div(内含按钮),点卡身与点开关等效(toggleMood);
3837
+ chip 点亮态走品牌 token,与头像选择行同一视觉语言。 */
3838
+ '.tg-mood-body{display:flex;flex-direction:column;gap:8px;padding:4px 2px 2px}',
3839
+ '.tg-mood-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
3840
+ '.tg-mood-row-label{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
3841
+ /* ── 心情问候卡(用户二轮返工重设计):分段选择器(segmented)替代散排 pill——
3842
+ 一组内相连、共享圆角、选中段"浮起",视觉整齐得多;区域四档放不下一行,用 2×2 网格。 */
3843
+ '.tg-mood-body{display:flex;flex-direction:column;gap:10px;padding:4px 2px 2px}',
3844
+ '.tg-mood-row{display:flex;align-items:center;gap:8px}',
3845
+ '.tg-mood-row-label{flex:none;width:26px;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
3846
+ /* 胶囊开关(一轮返工的版本保留):胶囊与文字分离,圆球滑块滑到端点。 */
3847
+ '.tg-mood-switch{display:inline-flex;align-items:center;gap:8px;height:24px;border:none;'
3848
+ + 'background:none;padding:0;cursor:pointer;font:inherit;font-size:12px;'
3849
+ + 'color:var(--dsw-alias-label-secondary)}',
3850
+ '.tg-mood-pill{position:relative;flex:none;width:40px;height:22px;border-radius:999px;'
3851
+ + 'background:var(--dsw-alias-bg-layer-3);box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l3);'
3852
+ + 'transition:background .18s var(--ds-ease-in-out)}',
3853
+ '.tg-mood-pill::after{content:"";position:absolute;top:2px;left:2px;width:18px;height:18px;'
3854
+ + 'border-radius:50%;background:var(--dsw-alias-label-primary);'
3855
+ + 'box-shadow:0 1px 3px rgba(0,0,0,.2);transition:transform .18s var(--ds-ease-in-out)}',
3856
+ '.tg-mood-switch[aria-checked="true"] .tg-mood-pill{background:var(--dsw-alias-state-business-primary);'
3857
+ + 'box-shadow:none}',
3858
+ '.tg-mood-switch[aria-checked="true"] .tg-mood-pill::after{transform:translateX(18px);background:#fff}',
3859
+ '.tg-mood-switch:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);'
3860
+ + 'outline-offset:2px;border-radius:12px}',
3861
+ /* 分段选择器:槽用 layer-3,选中段是浮起的"卡片底"小块 + 主文字色。 */
3862
+ '.tg-mood-seg{flex:1;display:inline-flex;gap:2px;background:var(--dsw-alias-bg-layer-3);'
3863
+ + 'border-radius:9px;padding:2px;box-sizing:border-box}',
3864
+ '.tg-mood-zone{flex:1;display:grid;grid-template-columns:1fr 1fr;gap:2px;'
3865
+ + 'background:var(--dsw-alias-bg-layer-3);border-radius:9px;padding:2px;box-sizing:border-box}',
3866
+ '.tg-mood-chip{height:20px;padding:0 6px;border:none;border-radius:7px;background:none;'
3867
+ + 'color:var(--dsw-alias-label-tertiary);font:inherit;font-size:11px;cursor:pointer;'
3868
+ + 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
3869
+ '.tg-mood-seg .tg-mood-chip{flex:1;min-width:0}',
3870
+ '.tg-mood-chip:hover{color:var(--dsw-alias-label-secondary)}',
3871
+ '.tg-mood-chip[aria-pressed="true"]{background:var(--dsw-alias-bg-base);'
3872
+ + 'color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.12)}',
3873
+ '.tg-mood-chip:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}',
3311
3874
  ].join('')
3312
3875
 
3313
3876
  /**
@@ -3323,6 +3886,9 @@ window.__ModuleLoader__.load({
3323
3886
  init: () => ({
3324
3887
  ids: [], labels: {}, descriptions: {}, swatches: {}, cardRows: {},
3325
3888
  scheme: '', selected: 'system', status: '', revision: -1,
3889
+ petEnabled: false, petKind: 'ban-ban',
3890
+ moodOn: true, moodSpeed: 'medium', moodDirection: 'ltr', moodZone: 'center',
3891
+ menuCollapsed: false,
3326
3892
  }),
3327
3893
  actions: {
3328
3894
  sync: (draft, themes, selected, revision) => {
@@ -3386,6 +3952,49 @@ window.__ModuleLoader__.load({
3386
3952
  * @param id - the scheme id.
3387
3953
  */
3388
3954
  markScheme: (draft, id) => { draft.scheme = String(id) },
3955
+ /**
3956
+ * 记录宠物挂件的开与关,给挂件卡的徽标与 aria-pressed 用。
3957
+ *
3958
+ * 与 `markScheme` 同一条理由:挂件状态不是主题,混进主题列表会让计数口径失真,
3959
+ * 所以单独放一个字段,由 `publish()` 从 localStorage 读出后写进来。
3960
+ * @param draft - store draft。
3961
+ * @param on - 挂件是否已开启。
3962
+ */
3963
+ markPet: (draft, on) => { draft.petEnabled = on === true },
3964
+ /**
3965
+ * 记住挂件当前是哪只宠物(头像选择行的点亮态与卡面图案都读它)。
3966
+ *
3967
+ * 与 `markPet` 同一条理由:宠物选择不是主题,单独一个字段,由 `publish()`
3968
+ * 从 localStorage 读出后写进来。**测试桩的 action 表必须跟着长**(规则 8:
3969
+ * 桩少一个 action,publish 就抛错、整个挂载被 guard 拦下)。
3970
+ * @param draft - store 草稿。
3971
+ * @param id - 宠物 id(PET_KINDS 之一)。
3972
+ */
3973
+ markPetKind: (draft, id) => { draft.petKind = String(id) },
3974
+ /**
3975
+ * 记录心情问候的开关与三组设置(速度/方向/区域),给第 14 张卡的
3976
+ * 胶囊开关、chip 点亮态与 aria-pressed 用。
3977
+ *
3978
+ * 与 `markPet` 同一条理由:问候状态不是主题,单独字段,由 `publish()`
3979
+ * 从 localStorage 读出后写进来。**测试桩的 action 表必须跟着长**(规则 8)。
3980
+ * @param draft - store 草稿。
3981
+ * @param state - `readMoodState()` 的消毒结果。
3982
+ */
3983
+ markMood: (draft, state) => {
3984
+ draft.moodOn = state.on === true
3985
+ draft.moodSpeed = String(state.speed)
3986
+ draft.moodDirection = String(state.direction)
3987
+ draft.moodZone = String(state.zone)
3988
+ },
3989
+ /**
3990
+ * 记录菜单折叠的开关状态,给折叠卡的徽标与 aria-pressed 用。
3991
+ *
3992
+ * 与 markPet/markMood 同一条理由:折叠状态不是主题,单独字段,由 `publish()`
3993
+ * 从 localStorage 读出后写进来。**测试桩的 action 表必须跟着长**(规则 8)。
3994
+ * @param draft - store 草稿。
3995
+ * @param on - 侧栏入口是否已折叠。
3996
+ */
3997
+ markMenuCollapse: (draft, on) => { draft.menuCollapsed = on === true },
3389
3998
  },
3390
3999
  })
3391
4000
  }
@@ -3465,25 +4074,197 @@ window.__ModuleLoader__.load({
3465
4074
  })
3466
4075
  }
3467
4076
 
4077
+ /**
4078
+ * 挂件卡面上的当前宠物与它的道具(静态装饰,markup 与运行时舞台同源 —— 都出自
4079
+ * PET_KINDS 注册表,结构上不可能漂移)。
4080
+ *
4081
+ * 卡面在面板里由 jsx 渲染,HTML 经 React 的正规注入通道 dangerouslySetInnerHTML
4082
+ * 进来:内容是插件自己的静态注册表字符串,没有任何用户输入,注入面为零。
4083
+ * @param kindId - 当前宠物 id(store 里的 petKind)。
4084
+ * @returns 卡面图案。
4085
+ */
4086
+ function petCardSceneHTML(kindId) {
4087
+ const kind = petKindById(kindId)
4088
+ // 卡面图案与舞台同源同比例:舞台显示尺寸(已含 PET_SCALE)再缩到 0.72。
4089
+ // 道具缩 0.9 与舞台一致 —— 卡面是舞台的缩影,不是注册表的直译。
4090
+ const scale = 0.72
4091
+ const pet = `<svg viewBox="${kind.viewBox}" width="${Math.round(petDisplaySize(kind).w * scale)}"`
4092
+ + ` height="${Math.round(petDisplaySize(kind).h * scale)}" aria-hidden="true">${kind.art.side}</svg>`
4093
+ const prop = `<svg viewBox="${kind.propViewBox}" width="${Math.round(petPropDisplaySize(kind).w * 0.9)}"`
4094
+ + ` height="${Math.round(petPropDisplaySize(kind).h * 0.9)}" aria-hidden="true">${kind.propArt}</svg>`
4095
+ return pet + prop
4096
+ }
4097
+
4098
+ /** 卡面图案的元素形态(排版交给 .tg-pet-art 的 flex)。 */
4099
+ function petCardArt(kindId) {
4100
+ return jsx('span', {
4101
+ className: 'tg-pet-art',
4102
+ dangerouslySetInnerHTML: { __html: petCardSceneHTML(kindId) },
4103
+ })
4104
+ }
4105
+
4106
+ /** 头像素描(选择行里那颗小圆头像)。 */
4107
+ function petFaceHTML(kind, edge) {
4108
+ return `<svg viewBox="${kind.faceViewBox}" width="${edge}" height="${edge}" aria-hidden="true">${kind.faceArt}</svg>`
4109
+ }
4110
+
4111
+ /**
4112
+ * 挂件卡上的头像选择行:每只宠物一颗头(7 个 button,绝不能套进 button 卡身 ——
4113
+ * 所以挂件卡的卡身是 div,与配色卡同一条规则)。
4114
+ * @param selectedKind - 当前宠物 id(点亮那一颗)。
4115
+ * @param onPick - 点头像的回调(参数是宠物 id)。
4116
+ * @returns 选择行元素。
4117
+ */
4118
+ function petPickerElement(selectedKind, onPick) {
4119
+ return jsx('span', {
4120
+ className: 'tg-petrow',
4121
+ children: PET_KINDS.map((kind, index) => jsx('button', {
4122
+ type: 'button',
4123
+ className: 'tg-petswatch',
4124
+ 'aria-pressed': kind.id === selectedKind,
4125
+ title: `${kind.home} · ${kind.species}`,
4126
+ // 与配色格同一条规矩:必须拦冒泡,否则"点了头像"先执行、随后被卡身的
4127
+ // 开关处理器覆盖回去(配色卡踩过的坑,见 schemeSwatch 的注释)。
4128
+ onClick: (event) => {
4129
+ if (event !== undefined && typeof event.stopPropagation === 'function') event.stopPropagation()
4130
+ onPick(kind.id)
4131
+ },
4132
+ dangerouslySetInnerHTML: { __html: petFaceHTML(kind, 26) },
4133
+ }, index)),
4134
+ })
4135
+ }
4136
+
4137
+ /** 方向的两档文案(卡面 chips 与自检行共用)。 */
4138
+ const MOOD_DIR_LABELS = { ltr: '自左至右', rtl: '自右至左' }
4139
+
4140
+ /**
4141
+ * 心情问候卡的卡面控件:胶囊开关(role="switch")+ 三组设置 chips。
4142
+ *
4143
+ * ── 为什么点什么都必须拦冒泡 ─────────────────────────────────────────────
4144
+ *
4145
+ * 卡身自己的 onClick = 开关问候;不拦冒泡的话,"点慢速"会先写速度、随后被
4146
+ * 卡身的开关处理器覆盖成一次 toggle —— 配色卡色格踩过的同一个坑
4147
+ * (schemeSwatch 注释里的原话)。事件参数要容错:jsx 桩调用处理器时不给事件。
4148
+ * @param props - `{ mood, onToggleMood, onSetMood, t }`:消毒后的状态、开关回调、
4149
+ * 设置回调、字典(取 已开启/已关闭 文案)。
4150
+ * @returns 卡面控件元素。
4151
+ */
4152
+ function moodControlsElement({ mood, onToggleMood, onSetMood, dict }) {
4153
+ const swallowEvent = (event) => {
4154
+ if (event !== undefined && typeof event.stopPropagation === 'function') event.stopPropagation()
4155
+ }
4156
+ const chip = (label, pressed, title, onPick) => jsx('button', {
4157
+ type: 'button',
4158
+ className: 'tg-mood-chip',
4159
+ 'aria-pressed': pressed,
4160
+ title,
4161
+ onClick: (event) => { swallowEvent(event); onPick() },
4162
+ children: label,
4163
+ })
4164
+ return jsxs('div', {
4165
+ className: 'tg-mood-body',
4166
+ children: [
4167
+ jsxs('div', {
4168
+ className: 'tg-mood-row',
4169
+ children: [
4170
+ // 胶囊(圆球滑块)+ 状态文字分离 —— 用户返工:去掉「标题栏问候」、
4171
+ // 胶囊两端全半圆、开/关时圆球滑到一侧顶端。
4172
+ jsxs('button', {
4173
+ type: 'button',
4174
+ className: 'tg-mood-switch',
4175
+ role: 'switch',
4176
+ 'aria-checked': mood.on,
4177
+ title: '开或关标题栏的滚动问候(与点卡身等效)',
4178
+ onClick: (event) => { swallowEvent(event); onToggleMood() },
4179
+ children: [
4180
+ jsx('span', { className: 'tg-mood-pill', 'aria-hidden': 'true' }),
4181
+ jsx('span', { className: 'tg-mood-state', children: mood.on ? dict('petOn') : dict('petOff') }),
4182
+ ],
4183
+ }),
4184
+ ],
4185
+ }),
4186
+ jsx('div', {
4187
+ className: 'tg-mood-row',
4188
+ children: [
4189
+ jsx('span', { className: 'tg-mood-row-label', children: '速度' }),
4190
+ jsx('span', {
4191
+ className: 'tg-mood-seg',
4192
+ children: Object.keys(MOOD_SPEEDS).map((id) => chip(
4193
+ MOOD_SPEED_LABELS[id],
4194
+ mood.speed === id,
4195
+ `滚动速度:${MOOD_SPEED_LABELS[id]}`,
4196
+ () => { onSetMood({ speed: id }) },
4197
+ )),
4198
+ }),
4199
+ ],
4200
+ }),
4201
+ jsx('div', {
4202
+ className: 'tg-mood-row',
4203
+ children: [
4204
+ jsx('span', { className: 'tg-mood-row-label', children: '方向' }),
4205
+ jsx('span', {
4206
+ className: 'tg-mood-seg',
4207
+ children: Object.keys(MOOD_DIR_LABELS).map((id) => chip(
4208
+ MOOD_DIR_LABELS[id],
4209
+ mood.direction === id,
4210
+ `滚动方向:${MOOD_DIR_LABELS[id]}`,
4211
+ () => { onSetMood({ direction: id }) },
4212
+ )),
4213
+ }),
4214
+ ],
4215
+ }),
4216
+ jsx('div', {
4217
+ className: 'tg-mood-row',
4218
+ children: [
4219
+ jsx('span', { className: 'tg-mood-row-label', children: '区域' }),
4220
+ // 四档一行放不下(卡片 ~217px):2×2 网格,选中态与分段选择器同一语言。
4221
+ jsx('span', {
4222
+ className: 'tg-mood-zone',
4223
+ children: Object.keys(MOOD_ZONE_GEOMETRY).map((id) => chip(
4224
+ MOOD_ZONE_LABELS[id],
4225
+ mood.zone === id,
4226
+ `位置区域:${MOOD_ZONE_LABELS[id]}`,
4227
+ () => { onSetMood({ zone: id }) },
4228
+ )),
4229
+ }),
4230
+ ],
4231
+ }),
4232
+ ],
4233
+ })
4234
+ }
4235
+
3468
4236
  /**
3469
4237
  * Render one card.
3470
4238
  *
3471
- * Three layouts share the card: the default token strip, the palette picker (a
3472
- * theme that declares `card.rows`), and — for the picker — no description body,
3473
- * because the swatches already fill it. The description still becomes the tooltip
3474
- * (`title`), which is what the schema says the field is for.
4239
+ * Four layouts share the card: the default token strip, the palette picker (a
4240
+ * theme that declares `card.rows`), the pet-widget toggle card, and — for the
4241
+ * picker and the widget — no description body. The description still becomes
4242
+ * the tooltip (`title`), which is what the schema says the field is for.
4243
+ *
4244
+ * The pet-widget card is NOT a theme: clicking it never touches the theme
4245
+ * service, it toggles the widget (`onTogglePet`), and its badge shows the
4246
+ * widget's on/off state instead of "applied". Like the picker flag, the
4247
+ * widget flag is read off the DATA (the id table), never off a rendered
4248
+ * element — the jsx stub returns null while React returns elements, so an
4249
+ * element-identity test silently takes the wrong branch in every test.
3475
4250
  *
3476
4251
  * A picker card is a `div`, not a `button`: it CONTAINS 15 buttons, and nesting
3477
4252
  * buttons inside a button is invalid markup that browsers silently re-parent.
4253
+ * The pet-family card is a `div` for the same reason (7 portrait buttons).
3478
4254
  * @param props - id, label, description, swatches, rows, selected, applied,
3479
- * selectedScheme, onSelect, onPickScheme.
4255
+ * selectedScheme, onSelect, onPickScheme, onTogglePet, petEnabled,
4256
+ * petKind, onPickPet, t.
3480
4257
  * @returns the card element.
3481
4258
  */
3482
4259
  function ThemeCard(props) {
3483
4260
  const {
3484
4261
  id, label, description, swatches, rows, selected, applied,
3485
- selectedScheme, onSelect, onPickScheme, t,
4262
+ selectedScheme, onSelect, onPickScheme, onTogglePet, petEnabled, petKind, onPickPet,
4263
+ mood, onToggleMood, onSetMood, menuCollapsed, onToggleMenuCollapse, t,
3486
4264
  } = props
4265
+ const isWidget = id === PET_WIDGET.id
4266
+ const isMood = id === MOOD_WIDGET.id
4267
+ const isMenuCollapse = id === MENU_COLLAPSE_WIDGET.id
3487
4268
  // ── WHY THE FLAG, AND NOT `blocks !== null` ──────────────────────────────
3488
4269
  //
3489
4270
  // Whether this card is a picker has to be read off the DATA, not off the rendered
@@ -3500,24 +4281,93 @@ window.__ModuleLoader__.load({
3500
4281
  // the whole panel — the silent-failure shape this project keeps paying for.
3501
4282
  const shape = cardRowShape({ card: { rows } }, PALETTE_SCHEMES)
3502
4283
  const hasPicker = shape !== undefined
4284
+ // 挂件/问候/折叠卡的徽标永远在场(它就是"开关状态"的读数);其余卡保持原样:选中才亮。
4285
+ const badgeText = (isWidget || isMood)
4286
+ ? ((isWidget ? petEnabled === true : mood?.on === true) ? t('petOn') : t('petOff'))
4287
+ : isMenuCollapse
4288
+ ? (menuCollapsed === true ? t('menuCollapsed') : t('menuShown'))
4289
+ : applied
3503
4290
  const head = jsxs('div', {
3504
4291
  className: 'tg-card-top',
3505
4292
  children: [
3506
4293
  jsx('span', { className: 'tg-name', children: label || id }),
3507
- selected ? jsx('span', { className: 'tg-badge', children: applied }) : null,
4294
+ (selected || isWidget || isMood || isMenuCollapse)
4295
+ ? jsx('span', {
4296
+ className: (isWidget && petEnabled !== true) || (isMood && mood?.on !== true)
4297
+ || (isMenuCollapse && menuCollapsed !== true)
4298
+ ? 'tg-badge tg-badge-off'
4299
+ : 'tg-badge',
4300
+ children: badgeText,
4301
+ })
4302
+ : null,
3508
4303
  ],
3509
4304
  })
3510
4305
  const body = hasPicker
3511
4306
  ? cardPickerElement(shape, selectedScheme, onPickScheme, PALETTE_SCHEMES)
3512
- : swatches.length > 0
3513
- ? jsx('span', {
3514
- className: 'tg-strip',
3515
- children: swatches.map((colour, index) => jsx('span', { style: { background: colour } }, index)),
4307
+ : isWidget
4308
+ ? jsxs('div', {
4309
+ className: 'tg-pet-body',
4310
+ children: [
4311
+ petCardArt(petKind || DEFAULT_PET_KIND.id),
4312
+ petPickerElement(petKind || DEFAULT_PET_KIND.id, onPickPet),
4313
+ ],
3516
4314
  })
3517
- : null
3518
- // The picker fills the card, so it prints no body copy; `description` stays the
3519
- // tooltip, which is what the schema says the field is for.
3520
- const desc = !hasPicker && description
4315
+ : isMood
4316
+ ? moodControlsElement({
4317
+ mood: {
4318
+ on: mood?.on === true,
4319
+ speed: mood?.speed ?? 'medium',
4320
+ direction: mood?.direction ?? 'ltr',
4321
+ zone: mood?.zone ?? 'center',
4322
+ },
4323
+ onToggleMood,
4324
+ onSetMood,
4325
+ dict: t,
4326
+ })
4327
+ : isMenuCollapse
4328
+ ? jsxs('div', {
4329
+ className: 'tg-menu-body',
4330
+ children: [
4331
+ // 胶囊滑块(用户 2026-09-27 加):开关状态一眼可见,label 说明它管什么。
4332
+ // 样式/结构与心情卡的胶囊同款(类名有意复用 —— 视觉绝对一致,零新 CSS);
4333
+ // 点击拦冒泡 —— 卡身也响应点击,不拦会翻转两次。
4334
+ jsxs('div', {
4335
+ className: 'tg-mood-row',
4336
+ children: [
4337
+ jsx('span', { className: 'tg-mood-row-label', children: t('menuPillLabel') }),
4338
+ jsxs('button', {
4339
+ type: 'button',
4340
+ className: 'tg-mood-switch',
4341
+ role: 'switch',
4342
+ 'aria-checked': menuCollapsed === true,
4343
+ title: '开=收起侧栏的「主题皮肤」入口;关=恢复显示(与点卡身等效)',
4344
+ onClick: (event) => {
4345
+ if (event !== undefined && typeof event.stopPropagation === 'function') event.stopPropagation()
4346
+ onToggleMenuCollapse()
4347
+ },
4348
+ children: [
4349
+ jsx('span', { className: 'tg-mood-pill', 'aria-hidden': 'true' }),
4350
+ jsx('span', {
4351
+ className: 'tg-mood-state',
4352
+ children: menuCollapsed === true ? t('menuCollapsed') : t('menuShown'),
4353
+ }),
4354
+ ],
4355
+ }),
4356
+ ],
4357
+ }),
4358
+ jsx('span', { className: 'tg-path', children: t('menuHint') }),
4359
+ ],
4360
+ })
4361
+ : swatches.length > 0
4362
+ ? jsx('span', {
4363
+ className: 'tg-strip',
4364
+ children: swatches.map((colour, index) => jsx('span', { style: { background: colour } }, index)),
4365
+ })
4366
+ : null
4367
+ // The picker fills the card and the widget/mood/menu cards draw their own controls,
4368
+ // so none of them prints body copy; `description` stays the tooltip, which is
4369
+ // what the schema says the field is for.
4370
+ const desc = !hasPicker && !isWidget && !isMood && !isMenuCollapse && description
3521
4371
  ? jsx('span', { className: 'tg-desc', children: description })
3522
4372
  : null
3523
4373
  if (hasPicker) {
@@ -3534,6 +4384,43 @@ window.__ModuleLoader__.load({
3534
4384
  children: [head, body, desc],
3535
4385
  })
3536
4386
  }
4387
+ if (isWidget) {
4388
+ // 挂件卡是 div 而不是 button:卡里现在装着 7 颗头像按钮,button 套 button 是
4389
+ // 非法标记(浏览器会静默重排),与配色卡同一条规则。点卡身 = 开关挂件,
4390
+ // **绝不**调用 setTheme —— 这是它与任何皮肤同时开启的前提;点头像 = 换宠物
4391
+ // (selectPetWidget,见挂载体)。开关状态由徽标文字(已开启/已关闭)表达,
4392
+ // "选中的是哪只"由头像的 aria-pressed 表达。
4393
+ return jsxs('div', {
4394
+ className: 'tg-card tg-pet-card',
4395
+ onClick: () => { onTogglePet() },
4396
+ title: description || label,
4397
+ children: [head, body, desc],
4398
+ })
4399
+ }
4400
+ if (isMood) {
4401
+ // 心情问候卡与挂件卡同一条规矩:div 卡身(内含胶囊开关与 chips 按钮)、
4402
+ // 点卡身 = 开关问候(toggleMood),**绝不**调用 setTheme;开关状态由徽标与
4403
+ // 胶囊开关的 aria-checked 表达,三组设置的点亮态由各 chip 的 aria-pressed 表达。
4404
+ return jsxs('div', {
4405
+ className: 'tg-card tg-mood-card',
4406
+ onClick: () => { onToggleMood() },
4407
+ title: description || label,
4408
+ children: [head, body, desc],
4409
+ })
4410
+ }
4411
+ if (isMenuCollapse) {
4412
+ // 菜单折叠卡:卡身是 **div**(与挂件卡/问候卡同构)——卡内现在装着胶囊按钮
4413
+ // (button 套 button 非法,浏览器会静默重排)。点卡身 = 开关折叠(保留),
4414
+ // 开关语义由**胶囊的 role="switch" + aria-checked** 与徽标表达;**绝不**调用
4415
+ // setTheme —— 折叠只改侧栏入口显隐、零主题写入。卡面正文是路径提示 +
4416
+ // 胶囊行(用户要求把找回路径写在卡上、胶囊让状态一眼可见)。
4417
+ return jsxs('div', {
4418
+ className: 'tg-card tg-menu-card',
4419
+ onClick: () => { onToggleMenuCollapse() },
4420
+ title: description || label,
4421
+ children: [head, body, desc],
4422
+ })
4423
+ }
3537
4424
  return jsxs('button', {
3538
4425
  type: 'button',
3539
4426
  className: 'tg-card',
@@ -6049,7 +6936,7 @@ window.__ModuleLoader__.load({
6049
6936
  // (「切到内置主题后装饰层保留」) — do not "fix" it by clearing the layer here.
6050
6937
  removeAllAmbientSeats()
6051
6938
  if (record) noteAmbientAttempt({ kind, column: true, band: '无装饰' })
6052
- ambientReport = { found: true, kind: '(none)', note: '该主题无装饰' }
6939
+ ambientReport = { found: true, kind: '(none)', note: '该主题无装饰', css: ambientStylesheetReady() }
6053
6940
  return
6054
6941
  }
6055
6942
 
@@ -6569,100 +7456,2813 @@ window.__ModuleLoader__.load({
6569
7456
  })
6570
7457
  }
6571
7458
 
6572
- /**
6573
- * Report whether the accent token layer actually reached the theme snapshot.
6574
- *
6575
- * ── WHY THIS READING IS NEEDED ───────────────────────────────────────────
6576
- *
6577
- * `syncAccent` stacks three tokens (`button-ghost-active-fill` / `-border` / `-hover`) with
6578
- * `ctx.theme.overrideTokens`. Whether that layer works had **never been verified**, and one
6579
- * earlier attempt to verify it used the wrong evidence: the active workspace FOLDER icon
6580
- * turns the accent colour, which was taken as proof — but that icon is painted by the skin's
6581
- * own `--dsw-alias-state-business-primary` token and has nothing to do with the layer.
6582
- *
6583
- * The service makes this checkable without any guesswork: `composeActive` folds every
6584
- * override layer into `snapshot.active.tokens` before publishing, so the composed value is
6585
- * readable straight off `getTheme()`.
7459
+ /* ═══════════════════ 宠物挂件运行时(宠物家族 × 7)═══════════════════
6586
7460
  *
6587
- * ── WHAT CAN AND CANNOT PROVE IT ─────────────────────────────────────────
7461
+ * 与侧栏素材(ambient)完全独立的第二层装饰:画在自己的固定层里,锚定**对话输入框**
7462
+ * (阅读态已验证的 composer 定位法),地面线 = 输入框下缘。规则照抄ambient 的教训:
6588
7463
  *
6589
- * The composed VALUE is the only channel that carries information, and only when it differs
6590
- * from what the skin declares for that same token:
7464
+ * · 幂等判据(廉价指纹)在**一切 DOM 写入之前**(规则 3);
7465
+ * · 移动循环复用工厂级单帧助手 `step()`(本文件 requestAnimationFrame 只此一处),
7466
+ * 且只在跑动中自续,停下即断 —— 没有任何常驻定时器(闲聊是单个可取消句柄,见后);
7467
+ * · 脚印 / 气泡都有**数量上限**与超时回收,绝不无界增长;
7468
+ * · 不写主题服务:开关、宠物、名字、位置全部存 localStorage,绝不碰 preference;
7469
+ * · 容器 pointer-events:none,只有宠物与它的道具可交互 —— 绝不挡输入框的点击。
6591
7470
  *
6592
- * • both bundled skins already ship `button-ghost-active-*` in their own palettes, AND the
6593
- * palette layer supplies them too — so the token COUNT is 67 either way. An earlier
6594
- * comment here claimed a count that "does not move" disproves the layer; for these skins
6595
- * it cannot move, so the claim was wrong and the count is reported for context only;
6596
- * • 梦海游鱼 declares `#2B74B5` for that token but its accent is `#FFD166`, so reading back
6597
- * `#FFD166` proves the accent layer composed OVER the palette — the palette alone cannot
6598
- * produce it;
6599
- * • 山青婷彩's accent is `#E88BB0` and it declares `#E88BB0` for the same token, so its
6600
- * reading is identical whether or not the layer is there. That is a property of the SKIN,
6601
- * not of the layer, and the verdict below says so instead of implying the reading proved
6602
- * something.
7471
+ * ── 二期(宠物家族)───────────────────────────────────────────────────────
6603
7472
  *
6604
- * The verdict is therefore computed, never asserted: it compares the composed value with the
6605
- * skin's declared value and with the accent the layer was built from.
6606
- * @returns the reading, as one segment of the panel line.
7473
+ * "这只宠物长什么样、跑多快、说什么话"全部在下面的 PET_KINDS 注册表里,框架零硬编码:
7474
+ * 美术常量只存 <svg> 内部内容(不含标签本身),由 petSvg/petPropSvg 统一包裹 ——
7475
+ * 舞台、面板卡面、预览页三处共用同一份字符串,结构上不可能漂移。
7476
+ * 活性机制(闲聊 / 打字回头 / 生成中加油)见本区后半(petNoteInteraction 起)。
6607
7477
  */
6608
- function describeAccentLayer() {
6609
- try {
6610
- const theme = ctx?.theme
6611
- if (theme === undefined) return '激活层[无 theme 服务]'
6612
- const snapshot = theme.getTheme()
6613
- const active = snapshot?.active
6614
- const tokens = active?.tokens ?? {}
6615
- const TOKEN = '--dsw-alias-button-ghost-active-border'
6616
- const composed = tokens[TOKEN]
6617
- const definition = typeof active?.id === 'string' ? bundledTheme(active.id) : undefined
6618
- const declared = declaredToken(definition, TOKEN)
6619
- const accent = definition?.accent
6620
- const layers = snapshot?.overrides === undefined
6621
- ? '(服务未暴露)'
6622
- : String(snapshot.overrides.size ?? snapshot.overrides.length ?? '?')
6623
- // Computed, not assumed — see the note above. `不可分辨` is a real answer: it says this
6624
- // skin's own value coincides with its accent, so this reading proves nothing either way.
6625
- const verdict = accent === undefined
6626
- ? '非本包皮肤(无法判断)'
6627
- : accent === declared
6628
- ? '不可分辨(强调色=皮肤自备值)'
6629
- : composed === accent
6630
- ? '生效(强调色压过自备值)'
6631
- : composed === declared
6632
- ? '未生效(仍是自备值)'
6633
- : `未知(既非强调色也非自备值)`
6634
- return `激活层[id=${active?.id ?? '?'}`
6635
- + ` 层数=${layers}`
6636
- + ` token数=${Object.keys(tokens).length}(自备同名令牌,不构成判据)`
6637
- + ` ${TOKEN.replace('--dsw-alias-', '')}=${composed === undefined ? '(缺失)' : String(composed)}`
6638
- + ` 自备=${declared === undefined ? '(无)' : String(declared)}`
6639
- + ` 强调色=${accent ?? '(无)'}`
6640
- + ` 判据=${verdict}]`
6641
- } catch (error) {
6642
- return `激活层[读取失败: ${String(error && error.message ? error.message : error)}]`
6643
- }
6644
- }
6645
7478
 
7479
+ /** 挂件舞台的 id;也是清扫跨热重载残留节点的依据。 */
7480
+ const PET_STAGE_ID = 'dsh-theme-pet'
7481
+
7482
+ /** 跨物种一致的安全预算:脚印枚数 / 脚印寿命 / 同场气泡数。 */
7483
+ const PET_TRAIL_MAX = 14
7484
+ const PET_TRAIL_FADE_MS = 3500
7485
+ const PET_BUBBLE_MAX = 3
7486
+ /** hop 步态:一跳的水平长度与最高抬升(px)。 */
7487
+ const PET_HOP_LEN_PX = 34
7488
+ const PET_HOP_LIFT_PX = 9
6646
7489
  /**
6647
- * Render the main-column gallery page.
7490
+ * 视口回退时的地面线抬高量(px)。锚点(输入框)在屏上时地面线贴它的下缘;
7491
+ * 主题面板页等 composer 隐藏的场合走视口回退,宠物直接贴窗口底缘会被裁掉一截
7492
+ * (实机验收反馈)—— 抬高一段让整只宠物完整落地。
7493
+ */
7494
+ const PET_FALLBACK_GROUND_GAP = 48
7495
+
7496
+ /* ── 美术常量(<svg> 内部内容;斑斑是一期资产拆分,形状与色值零改动)────── */
7497
+
7498
+ const PET_BAN_SIDE = ''
7499
+ + '<path class="dsh-pet-tail" d="M15 36 Q4 31 6.5 21" stroke="#A9744F" stroke-width="5" fill="none" stroke-linecap="round" style="transform-origin:14px 36px"/>'
7500
+ + '<rect class="dsh-pet-leg dsh-pet-leg-b" x="19" y="41" width="7" height="13" rx="3" fill="#C79A6B" style="transform-origin:22px 42px"/>'
7501
+ + '<rect class="dsh-pet-leg dsh-pet-leg-a" x="41" y="41" width="7" height="13" rx="3" fill="#C79A6B" style="transform-origin:44px 42px"/>'
7502
+ + '<ellipse cx="32" cy="35" rx="17.5" ry="13.5" fill="#F5E7CE"/>'
7503
+ + '<ellipse cx="25" cy="31.5" rx="7" ry="5" fill="#C79A6B" opacity=".9"/>'
7504
+ + '<rect class="dsh-pet-leg dsh-pet-leg-a" x="25" y="42" width="7" height="13" rx="3" fill="#F5E7CE" style="transform-origin:28px 43px"/>'
7505
+ + '<rect class="dsh-pet-leg dsh-pet-leg-b" x="37" y="42" width="7" height="13" rx="3" fill="#FBF3E4" style="transform-origin:40px 43px"/>'
7506
+ + '<circle cx="46" cy="18" r="12.5" fill="#F5E7CE"/>'
7507
+ + '<circle cx="51.5" cy="9.5" r="5.2" fill="#C79A6B"/>'
7508
+ + '<ellipse cx="40" cy="10.5" rx="6" ry="8.6" fill="#A9744F" transform="rotate(-20 40 10.5)"/>'
7509
+ + '<ellipse cx="55" cy="22.5" rx="6.2" ry="5" fill="#FBF3E4"/>'
7510
+ + '<circle cx="58.6" cy="20.6" r="2.2" fill="#4A372F"/>'
7511
+ + '<circle cx="49" cy="16" r="1.9" fill="#3B2B23" class="dsh-pet-eye"/>'
7512
+ + '<path d="M54.5 27 q2.2 2 4.4 .4" stroke="#4A372F" stroke-width="1.2" fill="none" stroke-linecap="round"/>'
7513
+
7514
+ const PET_BAN_FRONT = ''
7515
+ + '<path d="M50 40 q7 -3 6 -9" stroke="#A9744F" stroke-width="4.6" fill="none" stroke-linecap="round"/>'
7516
+ + '<ellipse cx="24" cy="12" rx="5.6" ry="9" fill="#A9744F" transform="rotate(-17 24 12)"/>'
7517
+ + '<ellipse cx="40" cy="12" rx="5.6" ry="9" fill="#A9744F" transform="rotate(17 40 12)"/>'
7518
+ + '<circle cx="32" cy="19" r="13" fill="#F5E7CE"/>'
7519
+ + '<circle cx="38.5" cy="10.5" r="5" fill="#C79A6B"/>'
7520
+ + '<circle cx="27" cy="17.5" r="1.9" fill="#3B2B23" class="dsh-pet-eye"/>'
7521
+ + '<circle cx="37" cy="17.5" r="1.9" fill="#3B2B23" class="dsh-pet-eye"/>'
7522
+ + '<ellipse cx="32" cy="24.5" rx="7" ry="5.4" fill="#FBF3E4"/>'
7523
+ + '<circle cx="32" cy="22.4" r="2.2" fill="#4A372F"/>'
7524
+ + '<ellipse cx="32" cy="28.4" rx="2.9" ry="2.4" fill="#7C4A3A"/>'
7525
+ + '<ellipse cx="32" cy="41.5" rx="14" ry="10.5" fill="#F5E7CE"/>'
7526
+ + '<ellipse cx="32" cy="44" rx="8" ry="6.2" fill="#FBF3E4"/>'
7527
+ + '<ellipse cx="22.5" cy="38" rx="4.6" ry="3.6" fill="#C79A6B" opacity=".9"/>'
7528
+ + '<ellipse cx="25" cy="51" rx="4.2" ry="3.1" fill="#FBF3E4"/>'
7529
+ + '<ellipse cx="39" cy="51" rx="4.2" ry="3.1" fill="#FBF3E4"/>'
7530
+
7531
+ const PET_BAN_PROP = ''
7532
+ + '<circle cx="13" cy="12" r="9.5" fill="#E06A4E"/>'
7533
+ + '<path d="M5 8.5 Q13 4 21 8.5" stroke="#B84A33" stroke-width="1.6" fill="none" stroke-linecap="round"/>'
7534
+ + '<path d="M4.5 14 Q13 10 21.5 14" stroke="#B84A33" stroke-width="1.6" fill="none" stroke-linecap="round"/>'
7535
+ + '<path d="M8 18.6 Q13 15 18 18.6" stroke="#B84A33" stroke-width="1.4" fill="none" stroke-linecap="round"/>'
7536
+ + '<path d="M21.6 11 q5 1.4 3.8 6.4" stroke="#B84A33" stroke-width="1.5" fill="none" stroke-linecap="round"/>'
7537
+ + '<ellipse cx="9.6" cy="7.4" rx="2.6" ry="1.5" fill="#FFFFFF" opacity=".3" transform="rotate(-24 9.6 7.4)"/>'
7538
+
7539
+ const PET_BAN_FACE = ''
7540
+ + '<ellipse cx="8.5" cy="7.5" rx="3.4" ry="5" fill="#A9744F" transform="rotate(-18 8.5 7.5)"/>'
7541
+ + '<ellipse cx="19.5" cy="7.5" rx="3.4" ry="5" fill="#A9744F" transform="rotate(18 19.5 7.5)"/>'
7542
+ + '<circle cx="14" cy="15.5" r="9.6" fill="#F5E7CE"/>'
7543
+ + '<circle cx="17.5" cy="9.6" r="3.6" fill="#C79A6B"/>'
7544
+ + '<circle cx="10.4" cy="14.5" r="1.5" fill="#3B2B23" class="dsh-pet-eye"/>'
7545
+ + '<circle cx="17.6" cy="14.5" r="1.5" fill="#3B2B23" class="dsh-pet-eye"/>'
7546
+ + '<ellipse cx="14" cy="19" rx="4.6" ry="3.4" fill="#FBF3E4"/>'
7547
+ + '<circle cx="14" cy="17.8" r="1.6" fill="#4A372F"/>'
7548
+ + '<path d="M12.6 21.4 q1.4 1.2 2.8 0" stroke="#4A372F" stroke-width="1" fill="none" stroke-linecap="round"/>'
7549
+
7550
+ const PET_BAN_TRAIL = ''
7551
+ + '<g fill="#8F969C"><ellipse cx="6" cy="7.6" rx="3.1" ry="2.5"/>'
7552
+ + '<circle cx="2.7" cy="3.7" r="1.35"/><circle cx="5" cy="2.3" r="1.35"/>'
7553
+ + '<circle cx="7.3" cy="2.3" r="1.35"/><circle cx="9.5" cy="3.8" r="1.35"/></g>'
7554
+
7555
+ const PET_JU_SIDE = ''
7556
+ + '<path class="dsh-pet-tail" d="M14 36 Q2 28 7 13" stroke="#F2A65A" stroke-width="5" fill="none" stroke-linecap="round" style="transform-origin:14px 36px"/>'
7557
+ + '<rect class="dsh-pet-leg dsh-pet-leg-b" x="18" y="40" width="7" height="12" rx="3" fill="#E0A060" style="transform-origin:21px 41px"/>'
7558
+ + '<rect class="dsh-pet-leg dsh-pet-leg-a" x="40" y="40" width="7" height="12" rx="3" fill="#F2A65A" style="transform-origin:43px 41px"/>'
7559
+ + '<ellipse cx="29" cy="36" rx="17" ry="12.5" fill="#F2A65A"/>'
7560
+ + '<path d="M17 34 q7 -9 15 -9" stroke="#D98032" stroke-width="2.2" fill="none" stroke-linecap="round"/>'
7561
+ + '<path d="M26 28 q7 -7 14 -6" stroke="#D98032" stroke-width="2.2" fill="none" stroke-linecap="round"/>'
7562
+ + '<path d="M35 27 q6 -5 11 -2" stroke="#D98032" stroke-width="2.2" fill="none" stroke-linecap="round"/>'
7563
+ + '<ellipse cx="40" cy="39" rx="6.5" ry="5" fill="#FBE8CC"/>'
7564
+ + '<rect class="dsh-pet-leg dsh-pet-leg-a" x="24" y="41" width="7" height="11" rx="3" fill="#F2A65A" style="transform-origin:27px 42px"/>'
7565
+ + '<rect class="dsh-pet-leg dsh-pet-leg-b" x="35" y="41" width="7" height="11" rx="3" fill="#F2A65A" style="transform-origin:38px 42px"/>'
7566
+ + '<ellipse cx="27.5" cy="51.4" rx="3" ry="1.8" fill="#FBE8CC"/>'
7567
+ + '<ellipse cx="38.5" cy="51.4" rx="3" ry="1.8" fill="#FBE8CC"/>'
7568
+ + '<circle cx="45" cy="19" r="11" fill="#F2A65A"/>'
7569
+ + '<path d="M36 13 L33.5 4 L43.5 8 Z" fill="#F2A65A"/>'
7570
+ + '<path d="M37.4 11.6 L36.2 6.6 L41.4 9 Z" fill="#E79B72"/>'
7571
+ // 后耳:两只耳尖对称朝外上,基边两点都落在头圆上(实机验收"一个耳朵位置
7572
+ // 不对"——旧后耳的基点浮在头缘外 4.6 单位,看起来悬空长错了地方)。
7573
+ + '<path d="M48.5 9 L55.5 3.5 L54.5 12 Z" fill="#F2A65A"/>'
7574
+ + '<path d="M50.3 9.6 L54.4 5.6 L53.9 10.7 Z" fill="#E79B72"/>'
7575
+ + '<path d="M42 8.5 l1.6 3.6" stroke="#D98032" stroke-width="1.6" fill="none" stroke-linecap="round"/>'
7576
+ + '<path d="M46 8 l.6 3.8" stroke="#D98032" stroke-width="1.6" fill="none" stroke-linecap="round"/>'
7577
+ + '<path d="M49.6 8.8 l-.6 3.4" stroke="#D98032" stroke-width="1.6" fill="none" stroke-linecap="round"/>'
7578
+ + '<circle cx="49.5" cy="16.5" r="1.9" fill="#3F3A36" class="dsh-pet-eye"/>'
7579
+ // 嘴:小鼻三角贴脸前缘 + 从鼻下勾回的嘴弧 + 脸侧两根短须,与正面像同款
7580
+ // (实机验收"嘴巴像是鸟嘴"——旧版是一个凸出脸缘的实心三角,读成喙)。
7581
+ + '<path d="M54.8 19.9 l2.5 .9 l-1.9 1.9 z" fill="#C96F5E"/>'
7582
+ + '<path d="M56.4 23.2 q-1.5 1.5 -3.1 .8" stroke="#B98A5F" stroke-width="1" fill="none" stroke-linecap="round"/>'
7583
+ + '<path d="M51.6 20.6 l5.3 -1.1 M52.1 22.7 l5.3 .5" stroke="#B98A5F" stroke-width=".9" fill="none" stroke-linecap="round"/>'
7584
+ + '<path d="M18 27 Q26 20.5 36 19" stroke="#F8C48C" stroke-width="2" fill="none" stroke-linecap="round" opacity=".8"/>'
7585
+
7586
+ const PET_JU_FRONT = ''
7587
+ + '<path d="M45 46 q9 -2 8 -9" stroke="#F2A65A" stroke-width="4.6" fill="none" stroke-linecap="round"/>'
7588
+ + '<path d="M21 10 L18.5 1.5 L27.5 5.5 Z" fill="#F2A65A"/>'
7589
+ + '<path d="M22 8.6 L20.6 4.2 L25.8 6.4 Z" fill="#E79B72"/>'
7590
+ + '<path d="M41 10 L43.5 1.5 L34.5 5.5 Z" fill="#F2A65A"/>'
7591
+ + '<path d="M40 8.6 L41.4 4.2 L36.2 6.4 Z" fill="#E79B72"/>'
7592
+ + '<ellipse cx="31" cy="42" rx="13.5" ry="9.5" fill="#F2A65A"/>'
7593
+ + '<ellipse cx="31" cy="43" rx="8" ry="6.5" fill="#FBE8CC"/>'
7594
+ + '<path d="M20 40 q-2 4 1 7" stroke="#D98032" stroke-width="1.8" fill="none" stroke-linecap="round"/>'
7595
+ + '<path d="M42 40 q2 4 -1 7" stroke="#D98032" stroke-width="1.8" fill="none" stroke-linecap="round"/>'
7596
+ + '<ellipse cx="25" cy="50.6" rx="3.4" ry="2.4" fill="#F2A65A"/>'
7597
+ + '<ellipse cx="37" cy="50.6" rx="3.4" ry="2.4" fill="#F2A65A"/>'
7598
+ + '<circle cx="31" cy="17" r="12" fill="#F2A65A"/>'
7599
+ + '<path d="M27 6.5 l1.2 3.2" stroke="#D98032" stroke-width="1.5" fill="none" stroke-linecap="round"/>'
7600
+ + '<path d="M31 5.8 l.4 3.4" stroke="#D98032" stroke-width="1.5" fill="none" stroke-linecap="round"/>'
7601
+ + '<path d="M35 6.5 l-.8 3.2" stroke="#D98032" stroke-width="1.5" fill="none" stroke-linecap="round"/>'
7602
+ + '<circle cx="26" cy="16" r="2" fill="#3F3A36" class="dsh-pet-eye"/>'
7603
+ + '<circle cx="36" cy="16" r="2" fill="#3F3A36" class="dsh-pet-eye"/>'
7604
+ + '<path d="M29.8 20 l2.4 0 l-1.2 1.8 z" fill="#C96F5E"/>'
7605
+ + '<path d="M31 21.8 q-1.6 2 -3.4 1" stroke="#B98A5F" stroke-width="1" fill="none" stroke-linecap="round"/>'
7606
+ + '<path d="M31 21.8 q1.6 2 3.4 1" stroke="#B98A5F" stroke-width="1" fill="none" stroke-linecap="round"/>'
7607
+ + '<path d="M20 18 l-6 -1.6 M20 20.5 l-6 .4 M42 18 l6 -1.6 M42 20.5 l6 .4" stroke="#B98A5F" stroke-width=".9" fill="none" stroke-linecap="round"/>'
7608
+
7609
+ const PET_JU_PROP = ''
7610
+ + '<path d="M17 9 l5 -3.5 l-1.4 3.5 l1.4 3.5 z" fill="#D99A3F"/>'
7611
+ + '<ellipse cx="10" cy="9" rx="8" ry="4" fill="#E8B15C"/>'
7612
+ + '<path d="M7 6.4 q1 2.6 0 5.2" stroke="#D99A3F" stroke-width="1.1" fill="none" stroke-linecap="round"/>'
7613
+ + '<path d="M11 6 q1 2.8 0 5.6" stroke="#D99A3F" stroke-width="1.1" fill="none" stroke-linecap="round"/>'
7614
+ + '<circle cx="4.6" cy="8.2" r="1" fill="#5B4632"/>'
7615
+
7616
+ const PET_JU_FACE = ''
7617
+ + '<path d="M8 9 L5.5 2.5 L13 5.5 Z" fill="#F2A65A"/>'
7618
+ + '<path d="M8.6 7.9 L7.3 4.5 L11.6 6.2 Z" fill="#E79B72"/>'
7619
+ + '<path d="M20 9 L22.5 2.5 L15 5.5 Z" fill="#F2A65A"/>'
7620
+ + '<path d="M19.4 7.9 L20.7 4.5 L16.4 6.2 Z" fill="#E79B72"/>'
7621
+ + '<circle cx="14" cy="15" r="9.8" fill="#F2A65A"/>'
7622
+ + '<path d="M11.5 6.8 l.9 2.8 M14 6.4 l.3 3 M16.5 6.8 l-.7 2.8" stroke="#D98032" stroke-width="1.4" fill="none" stroke-linecap="round"/>'
7623
+ + '<circle cx="10.4" cy="14.5" r="1.5" fill="#3F3A36" class="dsh-pet-eye"/>'
7624
+ + '<circle cx="17.6" cy="14.5" r="1.5" fill="#3F3A36" class="dsh-pet-eye"/>'
7625
+ + '<path d="M13 18 l2 0 l-1 1.5 z" fill="#C96F5E"/>'
7626
+ + '<path d="M14 19.6 q-1.2 1.4 -2.4 .7 M14 19.6 q1.2 1.4 2.4 .7" stroke="#B98A5F" stroke-width=".9" fill="none" stroke-linecap="round"/>'
7627
+ + '<path d="M6.5 16.5 l-4 -1 M6.8 18.4 l-4 .5 M21.5 16.5 l4 -1 M21.2 18.4 l4 .5" stroke="#B98A5F" stroke-width=".8" fill="none" stroke-linecap="round"/>'
7628
+
7629
+ const PET_JU_TRAIL = ''
7630
+ + '<g fill="#A98F7A"><ellipse cx="5.5" cy="6.8" rx="2.7" ry="2.1"/>'
7631
+ + '<circle cx="2.4" cy="3.3" r="1.15"/><circle cx="4.6" cy="2" r="1.15"/>'
7632
+ + '<circle cx="6.6" cy="2" r="1.15"/><circle cx="8.6" cy="3.4" r="1.15"/></g>'
7633
+
7634
+ const PET_TU_SIDE = ''
7635
+ + '<circle cx="7.5" cy="36" r="4.5" fill="#FFFFFF"/>'
7636
+ + '<ellipse cx="20" cy="42" rx="7.5" ry="6" fill="#EFE6D8"/>'
7637
+ + '<rect class="dsh-pet-leg dsh-pet-leg-a" x="26" y="42" width="5.5" height="10" rx="2.7" fill="#FBF6EF" style="transform-origin:28.7px 43px"/>'
7638
+ + '<rect class="dsh-pet-leg dsh-pet-leg-b" x="34" y="42" width="5.5" height="10" rx="2.7" fill="#F4EADF" style="transform-origin:36.7px 43px"/>'
7639
+ + '<ellipse cx="26" cy="38" rx="16" ry="12.5" fill="#FBF6EF"/>'
7640
+ + '<ellipse cx="28" cy="42" rx="9" ry="6" fill="#F4EADF" opacity=".7"/>'
7641
+ + '<path class="dsh-pet-ear" d="M37 5 Q30 6 31 16 Q31.5 24 35 24 Q38.5 24 38.5 16 Q38.5 8 37 5 Z" fill="#FBF6EF" style="transform-origin:37px 6px"/>'
7642
+ + '<path d="M34.6 9.5 Q32.6 11 33 17 Q33.3 21.5 35 21.5 Q36.7 21.5 36.7 16.5 Q36.6 11.5 34.6 9.5 Z" fill="#F2C9C4"/>'
7643
+ + '<circle cx="40" cy="23" r="10.5" fill="#FBF6EF"/>'
7644
+ + '<path d="M33 17 Q37 12.5 43 12" stroke="#FFFFFF" stroke-width="2" fill="none" stroke-linecap="round" opacity=".9"/>'
7645
+ + '<circle cx="44.5" cy="21.5" r="1.8" fill="#4A4038" class="dsh-pet-eye"/>'
7646
+ + '<path d="M49.5 23.8 l2.4 .6 l-1.7 1.7 z" fill="#EFA8A0"/>'
7647
+ + '<path d="M50.6 26.2 q-1.2 1.4 -2.8 .8" stroke="#C9A9A0" stroke-width="1" fill="none" stroke-linecap="round"/>'
7648
+ + '<circle cx="46.5" cy="25" r="1.6" fill="#F6CFC9" opacity=".85"/>'
7649
+
7650
+ const PET_TU_FRONT = ''
7651
+ + '<path class="dsh-pet-ear" d="M9 6 Q3 8 4.5 17 Q5.6 23.5 9 23 Q12 22.6 11.6 15 Q11.2 8.6 9 6 Z" fill="#FBF6EF" style="transform-origin:9px 7px"/>'
7652
+ + '<path d="M7.4 10.5 Q5.8 12 6.4 17 Q6.9 21 8.8 20.8 Q10.4 20.6 10.2 15.4 Q10 11.5 7.4 10.5 Z" fill="#F2C9C4"/>'
7653
+ + '<path class="dsh-pet-ear" d="M49 6 Q55 8 53.5 17 Q52.4 23.5 49 23 Q46 22.6 46.4 15 Q46.8 8.6 49 6 Z" fill="#FBF6EF" style="transform-origin:49px 7px"/>'
7654
+ + '<path d="M50.6 10.5 Q52.2 12 51.6 17 Q51.1 21 49.2 20.8 Q47.6 20.6 47.8 15.4 Q48 11.5 50.6 10.5 Z" fill="#F2C9C4"/>'
7655
+ + '<ellipse cx="29" cy="41" rx="12.5" ry="10" fill="#FBF6EF"/>'
7656
+ + '<ellipse cx="29" cy="42" rx="7.5" ry="6.5" fill="#FFFFFF"/>'
7657
+ + '<ellipse cx="24.5" cy="36" rx="2.6" ry="2" fill="#F4EADF"/>'
7658
+ + '<ellipse cx="33.5" cy="36" rx="2.6" ry="2" fill="#F4EADF"/>'
7659
+ + '<ellipse cx="24" cy="50.6" rx="2.8" ry="2.2" fill="#FBF6EF"/>'
7660
+ + '<ellipse cx="34" cy="50.6" rx="2.8" ry="2.2" fill="#FBF6EF"/>'
7661
+ + '<circle cx="29" cy="17" r="11.5" fill="#FBF6EF"/>'
7662
+ + '<circle cx="24" cy="16" r="1.9" fill="#4A4038" class="dsh-pet-eye"/>'
7663
+ + '<circle cx="34" cy="16" r="1.9" fill="#4A4038" class="dsh-pet-eye"/>'
7664
+ + '<path d="M28 20 l2 0 l-1 1.6 z" fill="#EFA8A0"/>'
7665
+ + '<path d="M29 21.6 q-1.4 1.6 -2.8 .8 M29 21.6 q1.4 1.6 2.8 .8" stroke="#C9A9A0" stroke-width="1" fill="none" stroke-linecap="round"/>'
7666
+ + '<rect x="27.2" y="22.6" width="1.7" height="2.2" rx=".6" fill="#FFFFFF"/>'
7667
+ + '<rect x="29.9" y="22.6" width="1.7" height="2.2" rx=".6" fill="#FFFFFF"/>'
7668
+ + '<circle cx="20.5" cy="19.5" r="1.8" fill="#F6CFC9"/>'
7669
+ + '<circle cx="37.5" cy="19.5" r="1.8" fill="#F6CFC9"/>'
7670
+
7671
+ const PET_TU_PROP = ''
7672
+ + '<path d="M12 8 q-2.5 -5 -6 -6 q2 5 6 6 z" fill="#67B35F"/>'
7673
+ + '<path d="M13 8 q2.5 -5 6 -6 q-2 5 -6 6 z" fill="#4E9B4A"/>'
7674
+ + '<path d="M12 7 Q16 10 15 15 Q14 20 12 21 Q10 20 9 15 Q8 10 12 7 Z" fill="#EF8A3C"/>'
7675
+ + '<path d="M10 11.5 q2 1 4 0 M9.7 15 q2.3 1 4.6 0" stroke="#D9702A" stroke-width="1.1" fill="none" stroke-linecap="round"/>'
7676
+
7677
+ const PET_TU_FACE = ''
7678
+ + '<path d="M6.5 8 Q3.5 12 5 17 Q6 20 8 19 Q9.8 18 9.4 13 Q9 9 6.5 8 Z" fill="#FBF6EF"/>'
7679
+ + '<path d="M6.9 10.5 Q5.9 13 6.7 16.4 Q7.4 18.2 8.3 17.8 Q9.2 17.4 9 13.6 Q8.8 11 6.9 10.5 Z" fill="#F2C9C4"/>'
7680
+ + '<path d="M21.5 8 Q24.5 12 23 17 Q22 20 20 19 Q18.2 18 18.6 13 Q19 9 21.5 8 Z" fill="#FBF6EF"/>'
7681
+ + '<path d="M21.1 10.5 Q22.1 13 21.3 16.4 Q20.6 18.2 19.7 17.8 Q18.8 17.4 19 13.6 Q19.2 11 21.1 10.5 Z" fill="#F2C9C4"/>'
7682
+ + '<circle cx="14" cy="14" r="9.6" fill="#FBF6EF"/>'
7683
+ + '<circle cx="10.3" cy="13.5" r="1.5" fill="#4A4038" class="dsh-pet-eye"/>'
7684
+ + '<circle cx="17.7" cy="13.5" r="1.5" fill="#4A4038" class="dsh-pet-eye"/>'
7685
+ + '<path d="M13 16.6 l2 0 l-1 1.5 z" fill="#EFA8A0"/>'
7686
+ + '<rect x="12.8" y="18.4" width="1.2" height="1.7" rx=".5" fill="#FFFFFF"/>'
7687
+ + '<rect x="14.4" y="18.4" width="1.2" height="1.7" rx=".5" fill="#FFFFFF"/>'
7688
+ + '<circle cx="7.6" cy="16.4" r="1.5" fill="#F6CFC9"/>'
7689
+ + '<circle cx="20.4" cy="16.4" r="1.5" fill="#F6CFC9"/>'
7690
+
7691
+ const PET_TU_TRAIL = ''
7692
+ + '<g fill="#C9BCA8"><circle cx="3" cy="4.5" r="1.3"/><circle cx="7" cy="3.5" r="1.3"/></g>'
7693
+
7694
+ const PET_KE_SIDE = ''
7695
+ + '<path d="M9.6 44 l-4.5 3 l5.2 .8 z" fill="#26303C"/>'
7696
+ + '<ellipse cx="20" cy="54.5" rx="4.6" ry="2.4" fill="#F5A623"/>'
7697
+ + '<ellipse cx="30" cy="54.5" rx="4.6" ry="2.4" fill="#E8961B"/>'
7698
+ + '<ellipse cx="26" cy="33" rx="17" ry="21" fill="#2E3A48"/>'
7699
+ + '<ellipse cx="30" cy="38" rx="11.5" ry="14" fill="#FDFDFB"/>'
7700
+ + '<path class="dsh-pet-wing" d="M12 24 Q6 32 10 42 Q14 40 15.5 31 Q15 25 12 24 Z" fill="#26303C" style="transform-origin:12px 25px"/>'
7701
+ + '<path d="M18 14 Q24 9.5 31 11" stroke="#3E4E60" stroke-width="2.4" fill="none" stroke-linecap="round" opacity=".9"/>'
7702
+ + '<circle cx="33.5" cy="22" r="2" fill="#242C36" class="dsh-pet-eye"/>'
7703
+ + '<circle cx="34.2" cy="21.3" r=".6" fill="#FFFFFF"/>'
7704
+ + '<path d="M42 24 l7 2.2 l-7 2.6 z" fill="#F5A623"/>'
7705
+
7706
+ const PET_KE_FRONT = ''
7707
+ + '<ellipse cx="19" cy="55" rx="4.4" ry="2.3" fill="#F5A623"/>'
7708
+ + '<ellipse cx="33" cy="55" rx="4.4" ry="2.3" fill="#F5A623"/>'
7709
+ + '<ellipse cx="26" cy="34" rx="17" ry="21" fill="#2E3A48"/>'
7710
+ + '<ellipse cx="26" cy="39" rx="12" ry="14.5" fill="#FDFDFB"/>'
7711
+ + '<path class="dsh-pet-wing" d="M9 26 Q3 34 7.5 44 Q12 42 13 32 Q12.4 26 9 26 Z" fill="#26303C" style="transform-origin:9px 27px"/>'
7712
+ + '<path class="dsh-pet-wing" d="M43 26 Q49 34 44.5 44 Q40 42 39 32 Q39.6 26 43 26 Z" fill="#26303C" style="transform-origin:43px 27px"/>'
7713
+ + '<path d="M16 13 Q22 8.5 29 10" stroke="#3E4E60" stroke-width="2.2" fill="none" stroke-linecap="round" opacity=".9"/>'
7714
+ + '<circle cx="20" cy="23" r="2" fill="#242C36" class="dsh-pet-eye"/>'
7715
+ + '<circle cx="32" cy="23" r="2" fill="#242C36" class="dsh-pet-eye"/>'
7716
+ + '<circle cx="20.7" cy="22.3" r=".6" fill="#FFFFFF"/>'
7717
+ + '<circle cx="32.7" cy="22.3" r=".6" fill="#FFFFFF"/>'
7718
+ + '<path d="M22.5 27.5 l7 0 l-3.5 4.4 z" fill="#F5A623"/>'
7719
+
7720
+ const PET_KE_PROP = ''
7721
+ + '<path d="M18 7 l4.4 -3 l-1 3 l1 3 z" fill="#C7D6E0"/>'
7722
+ + '<path d="M2 7 Q8 2.5 15 5.5 Q18.5 7 18.5 7 Q18.5 7 15 8.5 Q8 11.5 2 7 Z" fill="#9FB6C6"/>'
7723
+ + '<path d="M4 5.8 Q10 2.8 16 5.8" stroke="#7C96A8" stroke-width="1.4" fill="none" stroke-linecap="round"/>'
7724
+ + '<circle cx="4.8" cy="6.4" r=".9" fill="#33424E"/>'
7725
+
7726
+ const PET_KE_FACE = ''
7727
+ + '<circle cx="14" cy="15" r="10" fill="#2E3A48"/>'
7728
+ + '<ellipse cx="9.5" cy="14" rx="4" ry="4.6" fill="#FDFDFB"/>'
7729
+ + '<ellipse cx="18.5" cy="14" rx="4" ry="4.6" fill="#FDFDFB"/>'
7730
+ + '<path d="M9 7.5 Q12 5.6 15 6.4" stroke="#3E4E60" stroke-width="1.6" fill="none" stroke-linecap="round" opacity=".9"/>'
7731
+ + '<circle cx="9.5" cy="14" r="1.5" fill="#242C36" class="dsh-pet-eye"/>'
7732
+ + '<circle cx="18.5" cy="14" r="1.5" fill="#242C36" class="dsh-pet-eye"/>'
7733
+ + '<circle cx="10" cy="13.5" r=".5" fill="#FFFFFF"/>'
7734
+ + '<circle cx="19" cy="13.5" r=".5" fill="#FFFFFF"/>'
7735
+ + '<path d="M12 18.5 l4 0 l-2 2.6 z" fill="#F5A623"/>'
7736
+
7737
+ const PET_KE_TRAIL = ''
7738
+ + '<g fill="#8FA3B0"><ellipse cx="6" cy="7.6" rx="3.1" ry="2.5"/>'
7739
+ + '<circle cx="2.7" cy="3.7" r="1.35"/><circle cx="5" cy="2.3" r="1.35"/>'
7740
+ + '<circle cx="7.3" cy="2.3" r="1.35"/><circle cx="9.5" cy="3.8" r="1.35"/></g>'
7741
+
7742
+ const PET_SHU_SIDE = ''
7743
+ + '<circle cx="6.2" cy="26" r="1.6" fill="#E3C9A4"/>'
7744
+ + '<rect class="dsh-pet-leg dsh-pet-leg-b" x="12" y="31" width="4" height="5.5" rx="2" fill="#D9BE97" style="transform-origin:14px 32px"/>'
7745
+ + '<rect class="dsh-pet-leg dsh-pet-leg-a" x="22" y="31" width="4" height="5.5" rx="2" fill="#E3C9A4" style="transform-origin:24px 32px"/>'
7746
+ + '<rect class="dsh-pet-leg dsh-pet-leg-b" x="29" y="31" width="4" height="5.5" rx="2" fill="#E3C9A4" style="transform-origin:31px 32px"/>'
7747
+ + '<ellipse cx="21" cy="24" rx="16" ry="11" fill="#F0DCC0"/>'
7748
+ + '<ellipse cx="21" cy="18.5" rx="13" ry="5.5" fill="#E3C9A4" opacity=".8"/>'
7749
+ + '<ellipse class="dsh-pet-belly" cx="20" cy="28" rx="9" ry="4.5" fill="#F9EDD8" style="transform-origin:20px 28px"/>'
7750
+ + '<path d="M12 17.5 Q17 13.5 23 13.5" stroke="#F9EDD8" stroke-width="2" fill="none" stroke-linecap="round" opacity=".9"/>'
7751
+ + '<circle cx="31.5" cy="12.5" r="2.8" fill="#E3C9A4"/>'
7752
+ + '<circle cx="31.7" cy="12.6" r="1.5" fill="#EFB9A8"/>'
7753
+ + '<circle cx="29.5" cy="15.5" r="1.7" fill="#3F3630" class="dsh-pet-eye"/>'
7754
+ + '<ellipse cx="33.5" cy="22" rx="5" ry="4.2" fill="#F6E6CE"/>'
7755
+ + '<circle cx="38.8" cy="18.5" r="1.1" fill="#E2A69B"/>'
7756
+ + '<path d="M37.4 20.6 q1.2 1 2.4 .2" stroke="#C89F82" stroke-width=".9" fill="none" stroke-linecap="round"/>'
7757
+ + '<path d="M36.5 19.5 l5.5 -1 M36.8 20.6 l5.5 .6" stroke="#C9AD8C" stroke-width=".8" fill="none" stroke-linecap="round"/>'
7758
+
7759
+ const PET_SHU_FRONT = ''
7760
+ + '<ellipse cx="23" cy="26" rx="14" ry="10.5" fill="#F0DCC0"/>'
7761
+ + '<ellipse cx="23" cy="28" rx="8.5" ry="6.5" fill="#F9EDD8"/>'
7762
+ + '<ellipse cx="18.5" cy="36" rx="2.6" ry="1.8" fill="#E3C9A4"/>'
7763
+ + '<ellipse cx="27.5" cy="36" rx="2.6" ry="1.8" fill="#E3C9A4"/>'
7764
+ + '<circle cx="13.5" cy="19" r="6.5" fill="#F6E6CE"/>'
7765
+ + '<circle cx="32.5" cy="19" r="6.5" fill="#F6E6CE"/>'
7766
+ + '<circle cx="23" cy="17" r="9.5" fill="#F0DCC0"/>'
7767
+ + '<circle cx="16.5" cy="9" r="2.6" fill="#E3C9A4"/>'
7768
+ + '<circle cx="16.6" cy="9.1" r="1.3" fill="#EFB9A8"/>'
7769
+ + '<circle cx="29.5" cy="9" r="2.6" fill="#E3C9A4"/>'
7770
+ + '<circle cx="29.4" cy="9.1" r="1.3" fill="#EFB9A8"/>'
7771
+ + '<circle cx="19" cy="15.5" r="1.7" fill="#3F3630" class="dsh-pet-eye"/>'
7772
+ + '<circle cx="27" cy="15.5" r="1.7" fill="#3F3630" class="dsh-pet-eye"/>'
7773
+ + '<circle cx="23" cy="19.2" r="1.1" fill="#E2A69B"/>'
7774
+ + '<path d="M23 20.3 q-1.2 1.2 -2.3 .6 M23 20.3 q1.2 1.2 2.3 .6" stroke="#C89F82" stroke-width=".9" fill="none" stroke-linecap="round"/>'
7775
+ + '<rect x="21.7" y="21.4" width="1.9" height="2.4" rx=".7" fill="#FFFFFF"/>'
7776
+ + '<rect x="24.4" y="21.4" width="1.9" height="2.4" rx=".7" fill="#FFFFFF"/>'
7777
+ + '<ellipse cx="19.5" cy="29" rx="2.4" ry="2" fill="#E3C9A4"/>'
7778
+ + '<ellipse cx="26.5" cy="29" rx="2.4" ry="2" fill="#E3C9A4"/>'
7779
+ + '<ellipse cx="23" cy="28" rx="2" ry="1.3" fill="#4A4038"/>'
7780
+ + '<circle cx="24.6" cy="27.6" r=".7" fill="#F7F2E8"/>'
7781
+
7782
+ const PET_SHU_PROP = ''
7783
+ + '<path d="M3 7 Q7 2.5 13 4.5 Q17 6 17 7 Q17 8 13 9.5 Q7 11.5 3 7 Z" fill="#4A4038"/>'
7784
+ + '<path d="M7 4.2 l0 5.6 M10.5 3.8 l0 6.2 M14 4.8 l0 4.4" stroke="#2E2A26" stroke-width="1.1" fill="none" stroke-linecap="round"/>'
7785
+ + '<ellipse cx="16.8" cy="7" rx="1.5" ry="1.1" fill="#F7F2E8"/>'
7786
+
7787
+ const PET_SHU_FACE = ''
7788
+ + '<circle cx="8.5" cy="8.5" r="2.5" fill="#E3C9A4"/>'
7789
+ + '<circle cx="8.6" cy="8.6" r="1.2" fill="#EFB9A8"/>'
7790
+ + '<circle cx="19.5" cy="8.5" r="2.5" fill="#E3C9A4"/>'
7791
+ + '<circle cx="19.4" cy="8.6" r="1.2" fill="#EFB9A8"/>'
7792
+ + '<circle cx="14" cy="15.5" r="9.8" fill="#F0DCC0"/>'
7793
+ + '<circle cx="7.2" cy="18" r="3.8" fill="#F6E6CE"/>'
7794
+ + '<circle cx="20.8" cy="18" r="3.8" fill="#F6E6CE"/>'
7795
+ + '<circle cx="10.5" cy="14.5" r="1.5" fill="#3F3630" class="dsh-pet-eye"/>'
7796
+ + '<circle cx="17.5" cy="14.5" r="1.5" fill="#3F3630" class="dsh-pet-eye"/>'
7797
+ + '<circle cx="14" cy="17.6" r="1" fill="#E2A69B"/>'
7798
+ + '<rect x="12.6" y="18.8" width="1.3" height="1.9" rx=".5" fill="#FFFFFF"/>'
7799
+ + '<rect x="14.2" y="18.8" width="1.3" height="1.9" rx=".5" fill="#FFFFFF"/>'
7800
+
7801
+ const PET_SHU_TRAIL = ''
7802
+ + '<g fill="#B9A88C"><circle cx="2.5" cy="3.2" r="1.1"/><circle cx="5.6" cy="2.6" r="1.1"/></g>'
7803
+
7804
+ const PET_HU_SIDE = ''
7805
+ + '<g class="dsh-pet-tail" style="transform-origin:16px 34px">'
7806
+ + '<path d="M16 34 Q4 26 6.5 14 Q8 8 13 10 Q20 13 21.5 24 Q22 31 16 34 Z" fill="#D9663D"/>'
7807
+ + '<path d="M7 14 Q8.2 9.6 12 10.4 Q10.8 14.4 8.6 17.4 Q7.2 16 7 14 Z" fill="#FBF3E8"/></g>'
7808
+ + '<rect class="dsh-pet-leg dsh-pet-leg-b" x="18" y="38" width="6" height="12" rx="3" fill="#8A4A2F" style="transform-origin:21px 39px"/>'
7809
+ + '<rect class="dsh-pet-leg dsh-pet-leg-a" x="40" y="38" width="6" height="12" rx="3" fill="#8A4A2F" style="transform-origin:43px 39px"/>'
7810
+ + '<path d="M14 30 Q20 20 34 21 Q46 22 50 30 Q47 37 36 37.5 Q22 38 14 30 Z" fill="#D9663D"/>'
7811
+ + '<ellipse cx="22" cy="32" rx="6.5" ry="5.5" fill="#C25832" opacity=".9"/>'
7812
+ + '<ellipse cx="46" cy="31" rx="6" ry="6.5" fill="#FBF3E8"/>'
7813
+ + '<rect class="dsh-pet-leg dsh-pet-leg-a" x="24" y="38" width="5.5" height="12" rx="2.7" fill="#D9663D" style="transform-origin:26.7px 39px"/>'
7814
+ + '<rect class="dsh-pet-leg dsh-pet-leg-b" x="35" y="38" width="5.5" height="12" rx="2.7" fill="#D9663D" style="transform-origin:37.7px 39px"/>'
7815
+ + '<ellipse cx="27" cy="49.6" rx="2.8" ry="1.7" fill="#8A4A2F"/>'
7816
+ + '<ellipse cx="37.6" cy="49.6" rx="2.8" ry="1.7" fill="#8A4A2F"/>'
7817
+ + '<path d="M20 26 Q30 20.5 40 22" stroke="#E8845C" stroke-width="2.4" fill="none" stroke-linecap="round" opacity=".9"/>'
7818
+ + '<path d="M44.5 11.5 L43 2 L51 7.5 Z" fill="#D9663D"/>'
7819
+ + '<path d="M43 2 L47 5.6 L45.9 8.4 Z" fill="#4A372F"/>'
7820
+ + '<circle cx="51" cy="17" r="9.5" fill="#D9663D"/>'
7821
+ + '<path d="M49.5 10 L51 2 L56.5 8.5 Z" fill="#D9663D"/>'
7822
+ + '<path d="M51 2 L54.8 6.8 L52.9 9 Z" fill="#4A372F"/>'
7823
+ + '<circle cx="51.5" cy="15.5" r="1.8" fill="#3A2E28" class="dsh-pet-eye"/>'
7824
+ + '<path d="M56 15 Q63 16.5 63.5 19.5 Q60 22 55.6 21 Z" fill="#FBF3E8"/>'
7825
+ + '<circle cx="63" cy="18.6" r="1.5" fill="#3A2E28"/>'
7826
+ + '<path d="M57 20.8 q2.4 1.4 4.6 -.2" stroke="#8A4A2F" stroke-width=".9" fill="none" stroke-linecap="round"/>'
7827
+
7828
+ const PET_HU_FRONT = ''
7829
+ + '<path d="M52 42 Q62 38 61 28 Q57 30.5 54.5 36 Q53 39.5 52 42 Z" fill="#D9663D"/>'
7830
+ + '<path d="M61 28 Q58.6 29.6 57 33.4 Q59.6 32.6 61 28 Z" fill="#FBF3E8"/>'
7831
+ + '<ellipse cx="31" cy="40" rx="13" ry="10" fill="#D9663D"/>'
7832
+ + '<ellipse cx="31" cy="40" rx="7" ry="7.5" fill="#FBF3E8"/>'
7833
+ + '<rect x="25.5" y="44" width="5" height="7" rx="2.5" fill="#8A4A2F"/>'
7834
+ + '<rect x="33" y="44" width="5" height="7" rx="2.5" fill="#8A4A2F"/>'
7835
+ + '<path d="M22.5 9 L19.5 1 L27.5 5 Z" fill="#D9663D"/>'
7836
+ + '<path d="M19.5 1 L23.6 4.7 L22.2 8 Z" fill="#4A372F"/>'
7837
+ + '<path d="M39.5 9 L42.5 1 L34.5 5 Z" fill="#D9663D"/>'
7838
+ + '<path d="M42.5 1 L38.4 4.7 L39.8 8 Z" fill="#4A372F"/>'
7839
+ + '<path d="M20.5 14 l-4.5 -1.5 l3 5 z" fill="#D9663D"/>'
7840
+ + '<path d="M41.5 14 l4.5 -1.5 l-3 5 z" fill="#D9663D"/>'
7841
+ + '<circle cx="31" cy="16" r="11" fill="#D9663D"/>'
7842
+ + '<ellipse cx="31" cy="20.5" rx="6" ry="4.6" fill="#FBF3E8"/>'
7843
+ + '<circle cx="26" cy="15" r="1.9" fill="#3A2E28" class="dsh-pet-eye"/>'
7844
+ + '<circle cx="36" cy="15" r="1.9" fill="#3A2E28" class="dsh-pet-eye"/>'
7845
+ + '<ellipse cx="31" cy="18.6" rx="1.5" ry="1.2" fill="#3A2E28"/>'
7846
+ + '<path d="M31 19.8 q-1.5 1.6 -3 .8 M31 19.8 q1.5 1.6 3 .8" stroke="#8A4A2F" stroke-width=".9" fill="none" stroke-linecap="round"/>'
7847
+
7848
+ const PET_HU_PROP = ''
7849
+ + '<rect x="9.2" y="0.5" width="1.6" height="3" rx=".8" fill="#5E3D22"/>'
7850
+ + '<path d="M10 3 Q15.5 7 15 13 Q14.5 19.5 10 21.5 Q5.5 19.5 5 13 Q4.5 7 10 3 Z" fill="#9C6B3F"/>'
7851
+ + '<path d="M7 8 q3 1.6 6 0 M6.4 12 q3.6 1.8 7.2 0 M6.8 16 q3.2 1.7 6.4 0 M8.4 5 q1.6 1.2 3.2 0" stroke="#7C5230" stroke-width="1.2" fill="none" stroke-linecap="round"/>'
7852
+
7853
+ const PET_HU_FACE = ''
7854
+ + '<path d="M8 8 L5.5 1 L12 4.5 Z" fill="#D9663D"/>'
7855
+ + '<path d="M5.5 1 L9 3.9 L7.6 6.4 Z" fill="#4A372F"/>'
7856
+ + '<path d="M20 8 L22.5 1 L16 4.5 Z" fill="#D9663D"/>'
7857
+ + '<path d="M22.5 1 L19 3.9 L20.4 6.4 Z" fill="#4A372F"/>'
7858
+ + '<circle cx="14" cy="15" r="9.8" fill="#D9663D"/>'
7859
+ + '<ellipse cx="14" cy="19.5" rx="5.4" ry="4.2" fill="#FBF3E8"/>'
7860
+ + '<circle cx="10" cy="14" r="1.5" fill="#3A2E28" class="dsh-pet-eye"/>'
7861
+ + '<circle cx="18" cy="14" r="1.5" fill="#3A2E28" class="dsh-pet-eye"/>'
7862
+ + '<ellipse cx="14" cy="17.8" rx="1.3" ry="1" fill="#3A2E28"/>'
7863
+ + '<path d="M14 18.8 q-1.3 1.4 -2.6 .7 M14 18.8 q1.3 1.4 2.6 .7" stroke="#8A4A2F" stroke-width=".9" fill="none" stroke-linecap="round"/>'
7864
+
7865
+ const PET_HU_TRAIL = ''
7866
+ + '<g fill="#8F7A66"><ellipse cx="5.5" cy="6.6" rx="2.3" ry="1.9"/>'
7867
+ + '<circle cx="2.7" cy="3.5" r="1.05"/><circle cx="5" cy="2.3" r="1.05"/>'
7868
+ + '<circle cx="7.3" cy="2.3" r="1.05"/><circle cx="9.3" cy="3.7" r="1.05"/></g>'
7869
+
7870
+ const PET_XIONG_SIDE = ''
7871
+ // 熊猫的黑色分布(实机验收"完全不像是熊猫"后的重画):黑耳 / 大黑眼斑 /
7872
+ // 黑肩带连前肢 / 黑后臀连后腿,脸与身体中段是白 —— 旧版身体上下各一条
7873
+ // 孤立黑边(像被黑布勒住的白身体),观感不是熊猫。眼睛用黑斑里的白点
7874
+ // (旧版棕色眼在黑斑里看不清,眼斑读不出来)。
7875
+ + '<ellipse cx="15.5" cy="40" rx="9.5" ry="11" fill="#2E2A28"/>'
7876
+ + '<ellipse cx="30" cy="37" rx="16.5" ry="12.5" fill="#FAFAF7"/>'
7877
+ + '<ellipse cx="42" cy="34" rx="6.5" ry="9.5" fill="#2E2A28"/>'
7878
+ + '<rect class="dsh-pet-leg dsh-pet-leg-b" x="16" y="38" width="7.5" height="13" rx="3.7" fill="#2E2A28" style="transform-origin:19.7px 39px"/>'
7879
+ + '<rect class="dsh-pet-leg dsh-pet-leg-a" x="38.5" y="38" width="7.5" height="13" rx="3.7" fill="#2E2A28" style="transform-origin:42.2px 39px"/>'
7880
+ + '<ellipse class="dsh-pet-belly" cx="29" cy="42" rx="9" ry="7.5" fill="#FFFFFF" style="transform-origin:29px 42px"/>'
7881
+ + '<circle cx="38.5" cy="7" r="4.2" fill="#2E2A28"/>'
7882
+ + '<circle cx="53" cy="7" r="4.2" fill="#2E2A28"/>'
7883
+ + '<circle cx="45.5" cy="17" r="11.5" fill="#FAFAF7"/>'
7884
+ + '<ellipse cx="41" cy="15" rx="4.4" ry="5.6" fill="#2E2A28" transform="rotate(-18 41 15)"/>'
7885
+ + '<ellipse cx="51.5" cy="14.5" rx="4.4" ry="5.6" fill="#2E2A28" transform="rotate(14 51.5 14.5)"/>'
7886
+ + '<circle cx="41.5" cy="15.8" r="1.5" fill="#FFFFFF" class="dsh-pet-eye"/>'
7887
+ + '<circle cx="51" cy="15.2" r="1.5" fill="#FFFFFF" class="dsh-pet-eye"/>'
7888
+ + '<ellipse cx="46.8" cy="21.8" rx="2" ry="1.5" fill="#2E2A28"/>'
7889
+ + '<path d="M46.8 23.3 q-1.4 1.6 -3 1 M46.8 23.3 q1.2 1.6 2.6 .8" stroke="#6B6560" stroke-width="1" fill="none" stroke-linecap="round"/>'
7890
+
7891
+ const PET_XIONG_FRONT = ''
7892
+ // 正面:头下加黑色肩领(熊猫脖子处本就是黑肩带,头身连接也由它承载——
7893
+ // 白头直接坐在白身上会"头身分离",琥珀猫咪那次的教训),手臂加粗成
7894
+ // 圆肩短臂,眼斑放大让"黑眼圈"这个第一辨识特征读得出来。
7895
+ + '<circle cx="16" cy="8.5" r="4.6" fill="#2E2A28"/>'
7896
+ + '<circle cx="42" cy="8.5" r="4.6" fill="#2E2A28"/>'
7897
+ + '<ellipse cx="29" cy="39.5" rx="15.5" ry="11.5" fill="#FAFAF7"/>'
7898
+ + '<ellipse cx="29" cy="30" rx="11" ry="5" fill="#2E2A28"/>'
7899
+ + '<path d="M16.5 32 q-4.5 9 1 15.5 q7 -1.5 8.5 -8 Q24 33.5 16.5 32 Z" fill="#2E2A28"/>'
7900
+ + '<path d="M41.5 32 q4.5 9 -1 15.5 q-7 -1.5 -8.5 -8 Q34 33.5 41.5 32 Z" fill="#2E2A28"/>'
7901
+ + '<ellipse cx="21.5" cy="51" rx="5.4" ry="3" fill="#2E2A28"/>'
7902
+ + '<ellipse cx="36.5" cy="51" rx="5.4" ry="3" fill="#2E2A28"/>'
7903
+ + '<ellipse class="dsh-pet-belly" cx="29" cy="41" rx="8.5" ry="6.5" fill="#FFFFFF" style="transform-origin:29px 41px"/>'
7904
+ + '<circle cx="29" cy="16.5" r="12.5" fill="#FAFAF7"/>'
7905
+ + '<ellipse cx="22.5" cy="14.8" rx="4.8" ry="6" fill="#2E2A28" transform="rotate(-16 22.5 14.8)"/>'
7906
+ + '<ellipse cx="35.5" cy="14.8" rx="4.8" ry="6" fill="#2E2A28" transform="rotate(16 35.5 14.8)"/>'
7907
+ + '<circle cx="23" cy="15.6" r="1.55" fill="#FFFFFF" class="dsh-pet-eye"/>'
7908
+ + '<circle cx="35" cy="15.6" r="1.55" fill="#FFFFFF" class="dsh-pet-eye"/>'
7909
+ + '<ellipse cx="29" cy="20.8" rx="2.1" ry="1.6" fill="#2E2A28"/>'
7910
+ + '<path d="M29 22.4 q-1.4 1.6 -2.8 .9 M29 22.4 q1.4 1.6 2.8 .9" stroke="#6B6560" stroke-width="1" fill="none" stroke-linecap="round"/>'
7911
+
7912
+ const PET_XIONG_PROP = ''
7913
+ + '<path d="M10 3 Q9 .8 7.4 .6 Q9.6 -.2 10.9 1.4 Q10.7 2.4 10 3 Z" fill="#67B35F"/>'
7914
+ + '<path d="M10 3 Q15 8 14 14 Q13 20.5 10 22.5 Q7 20.5 6 14 Q5 8 10 3 Z" fill="#D9C08A"/>'
7915
+ + '<path d="M7.6 8.4 q2.4 1.6 4.8 0 M7 13 q3 1.8 6 0 M7.8 17.6 q2.2 1.5 4.4 0 M8.8 5.2 q1.2 1 2.4 0" stroke="#B99C63" stroke-width="1.3" fill="none" stroke-linecap="round"/>'
7916
+
7917
+ const PET_XIONG_FACE = ''
7918
+ + '<circle cx="7.5" cy="7" r="3.8" fill="#2E2A28"/>'
7919
+ + '<circle cx="20.5" cy="7" r="3.8" fill="#2E2A28"/>'
7920
+ + '<circle cx="14" cy="15.5" r="10" fill="#FAFAF7"/>'
7921
+ + '<ellipse cx="9.6" cy="14.3" rx="4.2" ry="5.2" fill="#2E2A28" transform="rotate(-15 9.6 14.3)"/>'
7922
+ + '<ellipse cx="18.4" cy="14.3" rx="4.2" ry="5.2" fill="#2E2A28" transform="rotate(15 18.4 14.3)"/>'
7923
+ + '<circle cx="9.9" cy="14.8" r="1.4" fill="#FFFFFF" class="dsh-pet-eye"/>'
7924
+ + '<circle cx="18.1" cy="14.8" r="1.4" fill="#FFFFFF" class="dsh-pet-eye"/>'
7925
+ + '<ellipse cx="14" cy="18.8" rx="1.8" ry="1.4" fill="#2E2A28"/>'
7926
+ + '<path d="M14 20.2 q-1.3 1.4 -2.6 .8 M14 20.2 q1.3 1.4 2.6 .8" stroke="#6B6560" stroke-width=".9" fill="none" stroke-linecap="round"/>'
7927
+
7928
+ const PET_XIONG_TRAIL = ''
7929
+ + '<g fill="#57534E"><ellipse cx="7" cy="8" rx="3.6" ry="2.8"/>'
7930
+ + '<circle cx="2.6" cy="4" r="1.15"/><circle cx="4.8" cy="2.6" r="1.15"/>'
7931
+ + '<circle cx="7" cy="2.2" r="1.15"/><circle cx="9.2" cy="2.7" r="1.15"/>'
7932
+ + '<circle cx="11.2" cy="4.2" r="1.15"/></g>'
7933
+
7934
+ /**
7935
+ * 宠物注册表 —— 二期的核心数据。顺序 = 面板头像行顺序 = 预览页顺序;
7936
+ * `ban-ban` 永远第一(缺省宠物)。全部字段是**数据**,不许出现函数。
6648
7937
  *
6649
- * `usePanelInfo` comes from the layout's GlobalStandardProps, so the page can
6650
- * render nothing when another panel is selected without the shell having to
6651
- * mount and unmount it.
6652
- * @param props - composed slot props.
6653
- * @returns the page element tree.
7938
+ * · `motion`:run(平滑直线)/ hop(抛物线蹦跳,起伏由运行时驱动)/ waddle(左右摇步)。
7939
+ * · `place`:未被拖过时的缺省摆位(视口右/下缘偏移;语义同一期)。
7940
+ * · `react.lines`:戳一下的两声;`cheer/done`:生成中加油 / 完成庆祝;`chat`:闲聊。
6654
7941
  */
6655
- function ThemeGalleryPage({ t, setTheme, pickScheme, useStore, usePanelInfo }) {
6656
- const info = usePanelInfo((s) => s.activePanelId)
6657
- const ids = useStore((s) => s.ids)
6658
- const labels = useStore((s) => s.labels)
6659
- const descriptions = useStore((s) => s.descriptions)
6660
- const swatches = useStore((s) => s.swatches)
6661
- const cardRows = useStore((s) => s.cardRows)
6662
- const scheme = useStore((s) => s.scheme)
6663
- const selected = useStore((s) => s.selected)
6664
- const status = useStore((s) => s.status)
6665
- // The layout keeps this page registered whatever the selection is, so it
7942
+ const PET_KINDS = [
7943
+ {
7944
+ id: 'ban-ban', species: '小奶狗', home: '斑斑',
7945
+ viewBox: '0 0 64 56', size: { w: 64, h: 56 },
7946
+ art: { side: PET_BAN_SIDE, front: PET_BAN_FRONT },
7947
+ propViewBox: '0 0 26 22', propSize: { w: 26, h: 22 }, propArt: PET_BAN_PROP,
7948
+ faceViewBox: '0 0 28 28', faceArt: PET_BAN_FACE,
7949
+ trail: { art: PET_BAN_TRAIL, w: 12, h: 11, everyPx: 26, side: 5 },
7950
+ motion: 'run', speed: 130,
7951
+ react: { lines: ['汪!', '汪汪!'], ms: 1450 },
7952
+ cheer: '汪汪!加油!', done: '汪汪!完成啦!',
7953
+ chat: [
7954
+ '汪…斑斑在看着你写代码~',
7955
+ '要不要休息一下?斑斑可以陪你玩毛线团!',
7956
+ '汪!今天的代码也写得棒棒的!',
7957
+ '(歪头)这个 bug 闻起来有线索的味道…',
7958
+ ],
7959
+ place: { pet: { dx: -100, dy: -56 }, prop: { dx: -132, dy: -22 } },
7960
+ },
7961
+ {
7962
+ id: 'da-ju', species: '橘猫', home: '大橘',
7963
+ viewBox: '0 0 62 54', size: { w: 62, h: 54 },
7964
+ art: { side: PET_JU_SIDE, front: PET_JU_FRONT },
7965
+ propViewBox: '0 0 24 18', propSize: { w: 24, h: 18 }, propArt: PET_JU_PROP,
7966
+ faceViewBox: '0 0 28 28', faceArt: PET_JU_FACE,
7967
+ trail: { art: PET_JU_TRAIL, w: 11, h: 10, everyPx: 22, side: 4 },
7968
+ motion: 'run', speed: 140,
7969
+ react: { lines: ['喵~?', '喵喵!'], ms: 1350 },
7970
+ cheer: '喵!在跑了在跑了!', done: '喵~搞定,可以吸猫了',
7971
+ chat: [
7972
+ '喵~敲键盘的手不可以摸鱼,但是可以摸猫',
7973
+ '代码写累了?本橘允许你吸三分钟猫',
7974
+ '喵呜…小鱼干和 commit,都再来一点嘛',
7975
+ '本橘监工中,摸鱼是会被记小本本的哦',
7976
+ ],
7977
+ place: { pet: { dx: -96, dy: -54 }, prop: { dx: -130, dy: -18 } },
7978
+ },
7979
+ {
7980
+ id: 'xue-qiu', species: '垂耳兔', home: '雪球',
7981
+ viewBox: '0 0 58 56', size: { w: 58, h: 56 },
7982
+ art: { side: PET_TU_SIDE, front: PET_TU_FRONT },
7983
+ propViewBox: '0 0 24 22', propSize: { w: 24, h: 22 }, propArt: PET_TU_PROP,
7984
+ faceViewBox: '0 0 28 28', faceArt: PET_TU_FACE,
7985
+ trail: { art: PET_TU_TRAIL, w: 10, h: 8, everyPx: 34, side: 0 },
7986
+ motion: 'hop', speed: 150,
7987
+ react: { lines: ['哼哼!', '蹦!'], ms: 1250 },
7988
+ cheer: '加油加油!蹦!', done: '写完啦!蹦蹦庆祝!',
7989
+ chat: [
7990
+ '胡萝卜含量:0。兔兔有点失望…',
7991
+ '蹦蹦跳跳,bug 全都跑掉!',
7992
+ '兔兔在呢,慢慢想,不着急~',
7993
+ '耳朵告诉你:这一版写得不错哦!',
7994
+ ],
7995
+ place: { pet: { dx: -92, dy: -56 }, prop: { dx: -124, dy: -22 } },
7996
+ },
7997
+ {
7998
+ id: 'bo-bo', species: '小企鹅', home: '波波',
7999
+ viewBox: '0 0 52 58', size: { w: 52, h: 58 },
8000
+ art: { side: PET_KE_SIDE, front: PET_KE_FRONT },
8001
+ propViewBox: '0 0 24 14', propSize: { w: 24, h: 14 }, propArt: PET_KE_PROP,
8002
+ faceViewBox: '0 0 28 28', faceArt: PET_KE_FACE,
8003
+ trail: { art: PET_KE_TRAIL, w: 12, h: 10, everyPx: 30, side: 6 },
8004
+ motion: 'waddle', speed: 110,
8005
+ react: { lines: ['嘎!', '嘎嘎!'], ms: 1300 },
8006
+ cheer: '嘎!冲鸭!', done: '嘎!收工,鱼干时间!',
8007
+ chat: [
8008
+ '嘎!南极的网速都没这么慢过…',
8009
+ '扑通扑通,为你打 call!',
8010
+ '企鹅不怕冷,你的代码也要不惧重构!',
8011
+ '鱼给你留着,记得吃饭呀~',
8012
+ ],
8013
+ place: { pet: { dx: -86, dy: -58 }, prop: { dx: -118, dy: -14 } },
8014
+ },
8015
+ {
8016
+ id: 'nuo-mi', species: '仓鼠', home: '糯米',
8017
+ viewBox: '0 0 46 38', size: { w: 46, h: 38 },
8018
+ art: { side: PET_SHU_SIDE, front: PET_SHU_FRONT },
8019
+ propViewBox: '0 0 20 14', propSize: { w: 20, h: 14 }, propArt: PET_SHU_PROP,
8020
+ faceViewBox: '0 0 28 28', faceArt: PET_SHU_FACE,
8021
+ trail: { art: PET_SHU_TRAIL, w: 8, h: 6, everyPx: 14, side: 3 },
8022
+ motion: 'run', speed: 170,
8023
+ react: { lines: ['吱!', '吱吱!'], ms: 1100 },
8024
+ cheer: '吱吱吱!跑起来!', done: '吱!存进颊囊啦!',
8025
+ chat: [
8026
+ '吱吱!瓜子分你一半,bug 也分你一半!',
8027
+ '仓鼠球蓄力中…能量 +1',
8028
+ '颊囊已清空,可以继续装需求了!',
8029
+ '吱…今天的代码香香的,像葵花籽',
8030
+ ],
8031
+ place: { pet: { dx: -78, dy: -38 }, prop: { dx: -108, dy: -14 } },
8032
+ },
8033
+ {
8034
+ id: 'a-chi', species: '小狐狸', home: '阿赤',
8035
+ viewBox: '0 0 66 52', size: { w: 66, h: 52 },
8036
+ art: { side: PET_HU_SIDE, front: PET_HU_FRONT },
8037
+ propViewBox: '0 0 20 24', propSize: { w: 20, h: 24 }, propArt: PET_HU_PROP,
8038
+ faceViewBox: '0 0 28 28', faceArt: PET_HU_FACE,
8039
+ trail: { art: PET_HU_TRAIL, w: 11, h: 10, everyPx: 24, side: 4 },
8040
+ motion: 'run', speed: 155,
8041
+ react: { lines: ['咕?', '咕咕!'], ms: 1250 },
8042
+ cheer: '咕!冲!', done: '咕~漂亮!收工!',
8043
+ chat: [
8044
+ '咕?这条路径,狐狸觉得可疑哦',
8045
+ '悄悄告诉你:尾巴比代码先跑完~',
8046
+ '森林法则第一条:先写测试!',
8047
+ '咕~松果给你,创意也给你!',
8048
+ ],
8049
+ place: { pet: { dx: -100, dy: -52 }, prop: { dx: -138, dy: -24 } },
8050
+ },
8051
+ {
8052
+ id: 'tuan-tuan', species: '熊猫', home: '团团',
8053
+ viewBox: '0 0 60 56', size: { w: 60, h: 56 },
8054
+ art: { side: PET_XIONG_SIDE, front: PET_XIONG_FRONT },
8055
+ propViewBox: '0 0 20 24', propSize: { w: 20, h: 24 }, propArt: PET_XIONG_PROP,
8056
+ faceViewBox: '0 0 28 28', faceArt: PET_XIONG_FACE,
8057
+ trail: { art: PET_XIONG_TRAIL, w: 14, h: 12, everyPx: 34, side: 6 },
8058
+ motion: 'waddle', speed: 95,
8059
+ react: { lines: ['嘿嘿!', '抱抱!'], ms: 1500 },
8060
+ cheer: '嘿嘿,冲呀!', done: '嘿嘿,完成!干饭!',
8061
+ chat: [
8062
+ '嘿嘿…竹子要一口一口吃,代码要一行一行写',
8063
+ '团团营业中,干饭与陪写两不误~',
8064
+ '黑眼圈是天生的,你的可不能熬出来!',
8065
+ '抱住竹笋,也抱住今天的 KPI!',
8066
+ ],
8067
+ place: { pet: { dx: -94, dy: -56 }, prop: { dx: -128, dy: -24 } },
8068
+ },
8069
+ ]
8070
+
8071
+ /** id → 注册表项;不认识的 id 一律回落第一只(缺省宠物)。 */
8072
+ const PET_KIND_BY_ID = new Map(PET_KINDS.map((kind) => [kind.id, kind]))
8073
+
8074
+ /** 缺省宠物 = 注册表第一只(ban-ban)。切换逻辑与兜底逻辑都以它为准。 */
8075
+ const DEFAULT_PET_KIND = PET_KINDS[0]
8076
+
8077
+ /** 宠物的显示尺寸(px)—— 注册表 size × PET_SCALE。消费尺寸一律走这里。 */
8078
+ function petDisplaySize(kind) {
8079
+ return { w: kind.size.w * PET_SCALE, h: kind.size.h * PET_SCALE }
8080
+ }
8081
+
8082
+ /** 道具的显示尺寸(px)。与宠物同一缩放:道具不同步放大就会显得相对变小。 */
8083
+ function petPropDisplaySize(kind) {
8084
+ return { w: kind.propSize.w * PET_SCALE, h: kind.propSize.h * PET_SCALE }
8085
+ }
8086
+
8087
+ /** 按注册表包裹一件姿势/道具/头像(<svg> 内部内容 → 完整 svg 字符串)。 */
8088
+ function petKindById(id) {
8089
+ return PET_KIND_BY_ID.get(id) ?? DEFAULT_PET_KIND
8090
+ }
8091
+
8092
+ /**
8093
+ * 包一层姿势 svg。cls 是 dsh-pet-side / dsh-pet-front(正反面由 CSS 切换显示)。
8094
+ * 只做拼接、不读任何运行态 —— 面板卡面与预览页都能单独调用它。
8095
+ */
8096
+ function petSvg(kind, pose, cls) {
8097
+ return `<svg class="${cls}" viewBox="${kind.viewBox}" aria-hidden="true">${kind.art[pose]}</svg>`
8098
+ }
8099
+
8100
+ /** 道具 svg(无类名:道具没有姿势切换)。 */
8101
+ function petPropSvg(kind) {
8102
+ return `<svg viewBox="${kind.propViewBox}" aria-hidden="true">${kind.propArt}</svg>`
8103
+ }
8104
+
8105
+ /**
8106
+ * 挂件状态的缺省值:默认关;名字按注册表逐只给(names 是 kind → 名字的表,
8107
+ * 每只宠物各自记住自己的名字);没拖过时按**当前宠物**的 place 摆位。
8108
+ */
8109
+ const PET_DEFAULTS = {
8110
+ on: false,
8111
+ kind: DEFAULT_PET_KIND.id,
8112
+ names: Object.fromEntries(PET_KINDS.map((kind) => [kind.id, kind.home])),
8113
+ placed: false,
8114
+ pet: { ...DEFAULT_PET_KIND.place.pet },
8115
+ prop: { ...DEFAULT_PET_KIND.place.prop },
8116
+ }
8117
+
8118
+ /** 读到不认识的 kind 时置位(自检行要用),下一次成功读取时清掉。 */
8119
+ let stateKindFallback = false
8120
+
8121
+ /**
8122
+ * 挂件运行态。`enabled` 是 localStorage 的内存镜像:resync 每帧都会进来,
8123
+ * 不能每次都读一遍 localStorage。`mode` = idle(待机)/ run(跑向道具)/
8124
+ * drag(被拖住)/ react(面对用户的反应)。
8125
+ *
8126
+ * 坐标语义(相对舞台左上角的 px):x/y 是宠物的**左上角**;propX 是道具的
8127
+ * **中心 x**、propY 是它的上缘。dir 是水平朝向:1 = 朝右(美术原方向),-1 = 朝左
8128
+ * (镜像)—— 缺省朝左,因为缺省摆位里道具就在宠物左侧,它一出生就该看着玩具。
8129
+ * hop 步态:hopFrac 是 0..1 的相位,lift 是当前视觉抬升(只影响显示,不动逻辑坐标)。
8130
+ */
8131
+ const petRun = {
8132
+ enabled: false, mode: 'idle', dir: -1, kindId: '',
8133
+ x: 0, y: 0, propX: 0, propY: 0, groundY: 0,
8134
+ hopFrac: 0, lift: 0,
8135
+ dragDX: 0, dragDY: 0, dragging: null,
8136
+ trailDist: 0, trailSide: 1, lastAt: 0, anchorKey: '', reactWasRunning: false,
8137
+ working: false, firstSyncAt: 0, everWorked: false,
8138
+ }
8139
+ /** 舞台与关键子节点的引用;null = 未挂载。 */
8140
+ let petStage = null
8141
+ const petRefs = { actor: null, prop: null, flip: null, figure: null, tag: null, name: null, input: null }
8142
+ /** 活着的脚印 / 气泡数量(有上限)。 */
8143
+ let petTrailCount = 0
8144
+ let petBubbleCount = 0
8145
+ /** react/cheer 的短时定时器;卸载时统一清掉,避免对已拆除的节点说话。 */
8146
+ let petTimers = []
8147
+ /** 本次模块实例的属主戳:热重载后旧实例留下的舞台会被清扫(ambient 同款做法)。 */
8148
+ const PET_OWNER = `p${Date.now()}`
8149
+
8150
+ /**
8151
+ * 读取并消毒挂件状态。
8152
+ *
8153
+ * localStorage 里的东西不可信:换版本、手改、损坏的 JSON 都可能遇到。宁可回到缺省,
8154
+ * 也不把一个 `undefined` 画进 transform。旧形状(一期:name/dog/ball 标量键)在这里
8155
+ * **原地迁移**:dog→pet、ball→prop、name→names['ban-ban'],用户实机已在一期存过
8156
+ * 数据,升级不该让他重新摆位、重新起名。
8157
+ * @returns `{ on, kind, names, placed, pet: {dx,dy}, prop: {dx,dy} }`。
8158
+ */
8159
+ function readPetState() {
8160
+ const defaults = () => ({
8161
+ on: PET_DEFAULTS.on,
8162
+ kind: PET_DEFAULTS.kind,
8163
+ names: { ...PET_DEFAULTS.names },
8164
+ placed: PET_DEFAULTS.placed,
8165
+ pet: { ...PET_DEFAULTS.pet },
8166
+ prop: { ...PET_DEFAULTS.prop },
8167
+ })
8168
+ try {
8169
+ const raw = window.localStorage?.getItem(PET_KEY)
8170
+ if (typeof raw !== 'string' || raw === '') return defaults()
8171
+ const parsed = JSON.parse(raw)
8172
+ if (parsed === null || typeof parsed !== 'object') return defaults()
8173
+ const out = defaults()
8174
+ const num = (value, fallback) =>
8175
+ (typeof value === 'number' && Number.isFinite(value) ? value : fallback)
8176
+ const offset = (value, fallback) => {
8177
+ const obj = value !== null && typeof value === 'object' ? value : {}
8178
+ return { dx: num(obj.dx, fallback.dx), dy: num(obj.dy, fallback.dy) }
8179
+ }
8180
+ // kind:不认识的(含被手改成乱码)一律回落缺省宠物,并留痕给自检行。
8181
+ stateKindFallback = false
8182
+ if (typeof parsed.kind === 'string' && PET_KIND_BY_ID.has(parsed.kind)) {
8183
+ out.kind = parsed.kind
8184
+ } else if (typeof parsed.kind === 'string' && parsed.kind !== '') {
8185
+ stateKindFallback = true
8186
+ }
8187
+ const kind = petKindById(out.kind)
8188
+ // 名字表:按宠物各自记忆;当前宠物保证有名字(缺 → 注册表的 home)。
8189
+ if (parsed.names !== null && typeof parsed.names === 'object') {
8190
+ for (const key of Object.keys(out.names)) {
8191
+ const value = parsed.names[key]
8192
+ if (typeof value === 'string' && value.trim() !== '') {
8193
+ out.names[key] = value.trim().slice(0, 10)
8194
+ }
8195
+ }
8196
+ }
8197
+ // 一期迁移:顶层 name 只属于斑斑;dog/ball 是 pet/prop 的旧键名。
8198
+ if (typeof parsed.name === 'string' && parsed.name.trim() !== '') {
8199
+ out.names['ban-ban'] = parsed.name.trim().slice(0, 10)
8200
+ }
8201
+ if (typeof out.names[kind.id] !== 'string' || out.names[kind.id] === '') {
8202
+ out.names[kind.id] = kind.home
8203
+ }
8204
+ out.on = parsed.on === true
8205
+ out.placed = parsed.placed === true
8206
+ const petOffset = parsed.pet !== undefined ? parsed.pet : parsed.dog
8207
+ const propOffset = parsed.prop !== undefined ? parsed.prop : parsed.ball
8208
+ out.pet = offset(petOffset, kind.place.pet)
8209
+ out.prop = offset(propOffset, kind.place.prop)
8210
+ return out
8211
+ } catch {
8212
+ return defaults()
8213
+ }
8214
+ }
8215
+
8216
+ /**
8217
+ * 合并写回挂件状态。写失败只记录 —— localStorage 被禁用时挂件仍可在本次会话里玩。
8218
+ * @param patch - 要合并进状态的部分字段。
8219
+ */
8220
+ function savePetState(patch) {
8221
+ try {
8222
+ window.localStorage?.setItem(PET_KEY, JSON.stringify({ ...readPetState(), ...patch }))
8223
+ } catch (error) {
8224
+ console.error('[theme-gallery] could not save the pet state:', error)
8225
+ }
8226
+ }
8227
+
8228
+ /** 挂件现在是否开着(给卡片徽标与同步判据用)。 */
8229
+ function petWidgetOn() {
8230
+ return readPetState().on
8231
+ }
8232
+
8233
+ /**
8234
+ * 挂件卡的"伪主题"形态。
8235
+ *
8236
+ * 面板卡片完全从主题注册表推导,而挂件不是主题 —— 所以给它一张**只在面板里存在**
8237
+ * 的卡片数据:走同一条排序与渲染管线(CARD_ORDER 序号、tooltip、禁用逻辑与其他卡
8238
+ * 一致),却不会被注册进主题服务(BUNDLED_THEMES 里没有它),官方外观列表也就
8239
+ * 多不出这一项。
8240
+ * @returns 只带卡片字段的对象(没有 tokens,默认色带为空)。
8241
+ */
8242
+ function petWidgetCardTheme() {
8243
+ return {
8244
+ id: PET_WIDGET.id,
8245
+ label: PET_WIDGET.label,
8246
+ description: PET_WIDGET.description,
8247
+ colorScheme: 'light',
8248
+ tokens: {},
8249
+ }
8250
+ }
8251
+
8252
+ /**
8253
+ * 心情问候卡的卡片数据(petWidgetCardTheme 同构):只在面板里存在的非主题卡,
8254
+ * 走同一条排序与渲染管线(CARD_ORDER 73、tooltip),不注册进主题服务,
8255
+ * 官方外观列表不会多出这一项。
8256
+ * @returns 只带卡片字段的对象(没有 tokens,默认色带为空)。
8257
+ */
8258
+ function moodCardTheme() {
8259
+ return {
8260
+ id: MOOD_WIDGET.id,
8261
+ label: MOOD_WIDGET.label,
8262
+ description: MOOD_WIDGET.description,
8263
+ colorScheme: 'light',
8264
+ tokens: {},
8265
+ }
8266
+ }
8267
+
8268
+ /**
8269
+ * 菜单折叠卡的卡片数据(moodCardTheme 同构):只在面板里存在的非主题卡,
8270
+ * 走同一条排序与渲染管线(CARD_ORDER 72、tooltip),不注册进主题服务,
8271
+ * 官方外观列表不会多出这一项。
8272
+ * @returns 只带卡片字段的对象(没有 tokens,默认色带为空)。
8273
+ */
8274
+ function menuCollapseCardTheme() {
8275
+ return {
8276
+ id: MENU_COLLAPSE_WIDGET.id,
8277
+ label: MENU_COLLAPSE_WIDGET.label,
8278
+ description: MENU_COLLAPSE_WIDGET.description,
8279
+ colorScheme: 'light',
8280
+ tokens: {},
8281
+ }
8282
+ }
8283
+
8284
+ /**
8285
+ * 输入框(composer 座位)的矩形。
8286
+ *
8287
+ * 这里**故意**不复用 mountGallery 里的 centreColumn / composerOf:那是挂载体内部的
8288
+ * 函数,工厂级的本函数够不到(硬性规则 4 的形状)。查找逻辑与它们一致,注释写明
8289
+ * 重复的原因 —— 两份实现谁坏了都会在"挂件不出现"上显形,而不是静默错位。
8290
+ *
8291
+ * ── 为什么不能只写 `.centerCol`(真机事故:挂件永远建不出来)────────────────
8292
+ *
8293
+ * 框架类名是**构建期哈希**:侧栏列在真机上叫 `ZTP-Xa_sidebarCol`,中心列同理是
8294
+ * `…_centerCol`。SKILL §13 的老教训在这里换了个形态复发——选择器命中 0 个元素、
8295
+ * 无异常无日志。所以中心列的查找必须像 `sidebarColumn()` 一样**哈希容忍**
8296
+ * (`[class*="_centerCol"]`),并再加两级兜底:全局找第一个 contenteditable /
8297
+ * textarea(聊天输入框几乎必然是其一);连它都没有时由调用方回退到视口定位。
8298
+ * @returns 矩形,或 null(连全局输入框都找不到)。
8299
+ */
8300
+ function petAnchorBox() {
8301
+ if (typeof document === 'undefined') return null
8302
+ const centre = document.querySelector('[data-windows-titlebar] .centerCol')
8303
+ || document.querySelector('[class*="_centerCol"]')
8304
+ || document.querySelector('[class*="centerCol" i]')
8305
+ const composer = (centre === null ? null
8306
+ : centre.querySelector('[contenteditable="true"]') || centre.querySelector('textarea'))
8307
+ || document.querySelector('[contenteditable="true"]')
8308
+ || document.querySelector('textarea')
8309
+ if (composer === null && centre === null) return null
8310
+ for (const node of [composer === null ? undefined : composer.parentElement, composer, centre]) {
8311
+ if (node === undefined || node === null) continue
8312
+ const rect = node.getBoundingClientRect()
8313
+ if (rect.width > 0 || rect.height > 0) return rect
8314
+ }
8315
+ return null
8316
+ }
8317
+
8318
+ /** 把 x 夹进窗口(狗按其半宽、球按其半径留边),拖出去也会被接住。 */
8319
+ function petClampX(x, half, width) {
8320
+ return Math.max(half, Math.min(x, Math.max(half, width - half)))
8321
+ }
8322
+
8323
+ /**
8324
+ * 视口尺寸,带兜底:个别环境读不到 innerWidth / innerHeight 时钳位会变成 NaN,
8325
+ * 狗会瞬间飞出屏幕 —— 与其信任它们存在,不如在这里回退成常见值。
8326
+ * @returns `{ width, height }`(px)。
8327
+ */
8328
+ function petViewport() {
8329
+ const pick = (value, fallback) =>
8330
+ (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback)
8331
+ try {
8332
+ return {
8333
+ width: pick(window.innerWidth, 1280),
8334
+ height: pick(window.innerHeight, 800),
8335
+ }
8336
+ } catch {
8337
+ return { width: 1280, height: 800 }
8338
+ }
8339
+ }
8340
+
8341
+ /** 指针坐标;桩环境给不出数字时返回 null(调用方直接忽略这一帧)。 */
8342
+ function petPointOf(event) {
8343
+ const x = event?.clientX
8344
+ const y = event?.clientY
8345
+ if (typeof x !== 'number' || typeof y !== 'number') return null
8346
+ return { x, y }
8347
+ }
8348
+
8349
+ /** 挂件层自己的样式表(与 PAGE_CSS 分开:它属于运行时舞台,不属于面板页面)。 */
8350
+ const PET_CSS = [
8351
+ `#${PET_STAGE_ID}{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:1200}`,
8352
+ // 弹窗打开时整层隐藏(body 标记类由 syncPetDialogHidden 维护)——
8353
+ // 挂件 z-index 高于模态弹窗,不藏会浮在弹窗内容上方(实机读数 2026-09-27)。
8354
+ 'body.dsh-dialog-open #dsh-theme-pet{display:none!important}',
8355
+ '.dsh-pet-actor,.dsh-pet-prop{position:absolute;left:0;top:0;pointer-events:auto;cursor:grab;will-change:transform}',
8356
+ '.dsh-pet-actor:active,.dsh-pet-prop:active{cursor:grabbing}',
8357
+ // flip 层与 figure 层必须分开:**镜像(内联 transform)绝不能和跑步动画(keyframes)
8358
+ // 放在同一个元素上** —— CSS 动画会覆盖内联 transform,放在一层就会"朝左跑时镜像
8359
+ // 失效、倒着跑"(真机事故)。尺寸由 buildPetStage 按注册表写内联(每只宠物不同)。
8360
+ '.dsh-pet-flip,.dsh-pet-figure{display:block;width:100%;height:100%}',
8361
+ '.dsh-pet-side,.dsh-pet-front{display:block;width:100%;height:100%}',
8362
+ '.dsh-pet-front{display:none}',
8363
+ '.dsh-pet-react .dsh-pet-side{display:none}',
8364
+ '.dsh-pet-react .dsh-pet-front{display:block}',
8365
+ // 眨眼:所有宠物共用(markup 里带 dsh-pet-eye 类的眼睛都会眨)。
8366
+ '.dsh-pet-eye{transform-box:fill-box;transform-origin:center;animation:dsh-pet-blink 4.6s infinite}',
8367
+ '@keyframes dsh-pet-blink{0%,91%,94.5%,100%{transform:scaleY(1)}92.8%{transform:scaleY(.12)}}',
8368
+ // 待机微动画锚点(幅度刻意克制:"活着",不是"表演");origin 由各宠物 markup 内联。
8369
+ '.dsh-pet-tail{animation:dsh-pet-wag 1.5s ease-in-out infinite}',
8370
+ '@keyframes dsh-pet-wag{from{transform:rotate(-7deg)}to{transform:rotate(9deg)}}',
8371
+ '.dsh-pet-ear{animation:dsh-pet-ear-flop 3.4s ease-in-out infinite}',
8372
+ '@keyframes dsh-pet-ear-flop{0%,86%,100%{transform:rotate(0)}91%{transform:rotate(7deg)}96%{transform:rotate(-5deg)}}',
8373
+ '.dsh-pet-wing{animation:dsh-pet-wing-idle 3s ease-in-out infinite}',
8374
+ '@keyframes dsh-pet-wing-idle{0%,88%,100%{transform:rotate(0)}93%{transform:rotate(-8deg)}}',
8375
+ '.dsh-pet-belly{animation:dsh-pet-breathe 3.8s ease-in-out infinite}',
8376
+ '@keyframes dsh-pet-breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.025)}}',
8377
+ // 跑动(run 步态):腿部摆动 + 身体起伏。
8378
+ '@keyframes dsh-pet-gallop-a{0%,100%{transform:rotate(17deg)}50%{transform:rotate(-17deg)}}',
8379
+ '@keyframes dsh-pet-gallop-b{0%,100%{transform:rotate(-15deg)}50%{transform:rotate(15deg)}}',
8380
+ '.dsh-pet-run .dsh-pet-leg-a{animation:dsh-pet-gallop-a .3s linear infinite}',
8381
+ '.dsh-pet-run .dsh-pet-leg-b{animation:dsh-pet-gallop-b .3s linear infinite}',
8382
+ '.dsh-pet-run .dsh-pet-figure{animation:dsh-pet-bob .3s ease-in-out infinite}',
8383
+ '@keyframes dsh-pet-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-1.6px)}}',
8384
+ // 摇摆步态(企鹅/熊猫):身体左右摇;不挂腿部动画(脚贴地)。
8385
+ '.dsh-pet-waddle .dsh-pet-figure{animation:dsh-pet-rock .46s ease-in-out infinite}',
8386
+ '@keyframes dsh-pet-rock{0%,100%{transform:rotate(-4deg)}50%{transform:rotate(4deg)}}',
8387
+ // 蹦跳步态(兔):无循环类,起伏由运行时驱动(petRun.lift),落地即脚印。
8388
+ // 戳一下:正面像 + 两次小跳;加油/庆祝/精神一下共用这条 hop(关键帧在后)。
8389
+ '@keyframes dsh-pet-hop{0%,100%{transform:translateY(0)}30%{transform:translateY(-5px)}60%{transform:translateY(-2px)}}',
8390
+ '.dsh-pet-react .dsh-pet-figure{animation:dsh-pet-hop .55s ease-in-out 2}',
8391
+ '.dsh-pet-cheer .dsh-pet-figure{animation:dsh-pet-hop .5s ease-in-out 2}',
8392
+ '.dsh-pet-perk .dsh-pet-figure{animation:dsh-pet-perk .9s ease-out 1}',
8393
+ '@keyframes dsh-pet-perk{0%{transform:scale(1)}30%{transform:scale(1.045)}100%{transform:scale(1)}}',
8394
+ // 名牌:opacity/visibility 显隐 + 隐藏侧 1.2s 延迟。实机验收:鼠标从宠物
8395
+ // 移向「改名/戳一下」的途中要穿过名牌与宠物之间的 5px 空隙,hover 一断名牌
8396
+ // 就消失。两处纯 CSS 的修法(不引 JS 定时器,符合有界性纪律):
8397
+ // · ::before 是跨过空隙的**透明桥**——名牌是 actor 的子元素,鼠标在桥上
8398
+ // 时 actor:hover 仍成立,可以一路走上名牌;
8399
+ // · 隐藏侧 visibility 延迟 1.2s:离开 actor 后名牌仍可交互一小会儿,
8400
+ // 鼠标来得及走上去。显示侧 delay 归零(transition-delay 作用于全部属性)。
8401
+ '.dsh-pet-tag{display:inline-flex;position:absolute;left:50%;bottom:calc(100% + 5px);',
8402
+ 'transform:translateX(-50%);pointer-events:auto;white-space:nowrap;z-index:3;',
8403
+ 'align-items:center;gap:4px;padding:3px 6px;border-radius:8px;background:rgba(40,33,28,.92);',
8404
+ 'color:#fff;font:11.5px/1.5 system-ui,"Segoe UI","Microsoft YaHei",sans-serif;',
8405
+ 'box-shadow:0 3px 10px rgba(0,0,0,.22);opacity:0;visibility:hidden;',
8406
+ 'transition:opacity .18s ease,visibility 0s linear 1.2s}',
8407
+ '.dsh-pet-tag::before{content:"";position:absolute;left:0;right:0;bottom:-12px;height:12px}',
8408
+ '.dsh-pet-actor:hover .dsh-pet-tag,.dsh-pet-tag.dsh-pet-editing{opacity:1;visibility:visible;transition-delay:0s}',
8409
+ '.dsh-pet-name{max-width:96px;overflow:hidden;text-overflow:ellipsis}',
8410
+ '.dsh-pet-actions{display:inline-flex;gap:4px}',
8411
+ '.dsh-pet-tag button{border:none;border-radius:5px;padding:1px 7px;cursor:pointer;',
8412
+ 'font:11px/1.6 inherit;color:#fff;background:rgba(255,255,255,.18)}',
8413
+ '.dsh-pet-tag button:hover{background:rgba(255,255,255,.34)}',
8414
+ '.dsh-pet-input{display:none;width:88px;border:none;border-radius:5px;padding:2px 6px;',
8415
+ 'font:11.5px/1.5 inherit;color:#2b2320}',
8416
+ '.dsh-pet-editing .dsh-pet-name,.dsh-pet-editing .dsh-pet-actions{display:none}',
8417
+ '.dsh-pet-editing .dsh-pet-input{display:block}',
8418
+ // 拖拽中的反馈:影子加深,让人看清"它离地了"。
8419
+ '.dsh-pet-dragging .dsh-pet-figure{filter:drop-shadow(0 7px 6px rgba(0,0,0,.28))}',
8420
+ // 脚印:随物种换形状与颜色(注册表 trail.art),落一次淡一次;
8421
+ // 超时由 JS 兜底回收(reduced-motion 关掉动画时也成立)。
8422
+ '.dsh-pet-trailmark{position:absolute;left:0;top:0;opacity:.5;',
8423
+ 'pointer-events:none;animation:dsh-pet-trail-fade 3.4s ease-out forwards}',
8424
+ '@keyframes dsh-pet-trail-fade{0%{opacity:.5}70%{opacity:.34}100%{opacity:0}}',
8425
+ // 叫声/闲聊/加油气泡。
8426
+ '.dsh-pet-bubble{position:absolute;left:0;top:0;pointer-events:none;white-space:nowrap;',
8427
+ 'padding:2px 8px;border-radius:9px;background:#fff;color:#3B2B23;border:1px solid #E4CBA2;',
8428
+ 'font:12px/1.6 system-ui,"Segoe UI","Microsoft YaHei",sans-serif;',
8429
+ 'box-shadow:0 2px 7px rgba(0,0,0,.16);animation:dsh-pet-bubble 1.15s ease-out forwards}',
8430
+ '@keyframes dsh-pet-bubble{0%{opacity:0;transform-origin:bottom left;scale:.6}',
8431
+ '14%{opacity:1;scale:1}78%{opacity:1;scale:1}100%{opacity:0;scale:1;translate:0 -9px}}',
8432
+ '@media (prefers-reduced-motion:reduce){',
8433
+ `#${PET_STAGE_ID} *{animation:none!important}`,
8434
+ // 名牌的 visibility 延迟是"鼠标能走上去"的功能窗口(透明桥),不是装饰动画:
8435
+ // reduced-motion 只去掉 opacity 淡入淡出,把交互窗口原样保留。
8436
+ `#${PET_STAGE_ID} .dsh-pet-tag{transition:visibility 0s linear 1.2s}}`,
8437
+ ].join('\n')
8438
+
8439
+ /**
8440
+ * 挂件层样式表的就位与自愈(与 ambient 的 ensureAmbientStylesheet 同一策略):
8441
+ * 每次同步都检查,不在就装、内容陈旧就换 —— "一次安装应该能活下来"这个假设
8442
+ * 在这台机器上已经失败过一次。
8443
+ * @returns 样式表元素,或 null(没有 document)。
8444
+ */
8445
+ function ensurePetStylesheet() {
8446
+ if (typeof document === 'undefined') return null
8447
+ const existing = document.querySelector('style[data-plugin-css="theme-gallery/pet"]')
8448
+ if (existing !== null) {
8449
+ if (existing.textContent === PET_CSS) return existing
8450
+ existing.remove()
8451
+ }
8452
+ const tag = document.createElement('style')
8453
+ tag.dataset.plugin = 'theme-gallery'
8454
+ tag.dataset.pluginCss = 'theme-gallery/pet'
8455
+ tag.textContent = PET_CSS
8456
+ if (document.head !== null) document.head.append(tag)
8457
+ else document.documentElement.append(tag)
8458
+ return tag
8459
+ }
8460
+
8461
+ /* ── 活性机制的常量与状态(闲聊 / 打字回头 / 生成中加油)───────────────────
8462
+ *
8463
+ * 三重有界:闲聊是**单个可取消句柄**(petChatterTimer,任意时刻至多一个);
8464
+ * 加油/庆祝只挂短时类名(句柄进 petTimers);打字回头是 document 上唯一的
8465
+ * input 监听(搭舞台才装、拆舞台即卸)。任何一级检测失效都静默降级。
8466
+ */
8467
+
8468
+ /** 闲聊:距最近互动至少 2 分钟才可能出现;之后每 2.5 分钟一个窗口。 */
8469
+ const CHATTER_FIRST_MS = 120000
8470
+ const CHATTER_GAP_MS = 150000
8471
+ /** 窗口内的随机延迟上限:避免"整点报时"的机械感,也挡住测试桩的立即定时器。 */
8472
+ const CHATTER_JITTER_MS = 90000
8473
+ /** 最近一次互动时刻(戳/拖/切宠/改名/加油都算);0 = 尚未发生过。 */
8474
+ let petLastInteractAt = 0
8475
+ /** 闲聊的单句柄;0 = 无排程。 */
8476
+ let petChatterTimer = 0
8477
+
8478
+ /** 打字回头:两次反应至少隔 15s;监听只此一个。 */
8479
+ const PET_TYPE_REACT_MS = 15000
8480
+ let petLastTypingAt = 0
8481
+
8482
+ /**
8483
+ * "模型正在生成"的 DOM 信号:composer 主按钮在生成中会把 aria-label 置为
8484
+ * 「停止生成」/「Stop generating」(app.asar 内嵌 InputBar 实测,无哈希、
8485
+ * 框架有意设置的语义属性 —— SKILL §13 认可的稳定钩子类型)。
8486
+ * 查不到 = 未在生成(或界面改版),两种都按"闲"处理,静默降级。
8487
+ */
8488
+ const PET_WORKING_SELECTORS = [
8489
+ 'button[aria-label="停止生成"]',
8490
+ 'button[aria-label="Stop generating"]',
8491
+ ]
8492
+ /** 最近一次确认"在生成"的时刻;用于"忙过又闲下来"才庆祝。 */
8493
+ let petLastWorkedAt = 0
8494
+
8495
+ /**
8496
+ * 记一次互动并重置闲聊计时。三机制共用的时间轴:宠物"和你玩过"之后,
8497
+ * 悄悄话才愿意再等一会儿。
8498
+ */
8499
+ function petNoteInteraction() {
8500
+ petLastInteractAt = Date.now()
8501
+ schedulePetChatter()
8502
+ }
8503
+
8504
+ /** 排一次闲聊(先清旧句柄;舞台不在就不排)。 */
8505
+ function schedulePetChatter() {
8506
+ if (petStage === null) return
8507
+ if (typeof window.clearTimeout === 'function') window.clearTimeout(petChatterTimer)
8508
+ const base = petLastInteractAt === 0 ? CHATTER_FIRST_MS : CHATTER_GAP_MS
8509
+ const wait = base + Math.floor(Math.random() * CHATTER_JITTER_MS)
8510
+ petChatterTimer = window.setTimeout(petChatterTick, wait)
8511
+ }
8512
+
8513
+ /**
8514
+ * 闲聊到点。三道闸都过才冒泡:舞台在、宠物待机、页面可见、距最近互动 ≥ 首闸。
8515
+ * 时间闸同时是**测试闸**:桩的 drive() 会把定时器立即执行,而桩里只过去了几毫秒,
8516
+ * 闸门挡住 → 测试不会看到伪闲聊(断言见 check-boot-path)。
8517
+ */
8518
+ function petChatterTick() {
8519
+ petChatterTimer = 0
8520
+ if (petStage === null) return
8521
+ if (petRun.mode !== 'idle') { schedulePetChatter(); return }
8522
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
8523
+ schedulePetChatter()
8524
+ return
8525
+ }
8526
+ const idleFor = Date.now() - petLastInteractAt
8527
+ if (petLastInteractAt === 0 || idleFor < CHATTER_FIRST_MS) { schedulePetChatter(); return }
8528
+ const kind = petKindById(petRun.kindId)
8529
+ const line = kind.chat[Math.floor(Math.random() * kind.chat.length)]
8530
+ if (typeof line === 'string' && line !== '') petBubble(line)
8531
+ petLastInteractAt = Date.now()
8532
+ schedulePetChatter()
8533
+ }
8534
+
8535
+ /** 是否检出"生成中"信号(找不到 = false,绝不抛错)。 */
8536
+ function petWorkingSignal() {
8537
+ if (typeof document === 'undefined' || typeof document.querySelector !== 'function') return false
8538
+ try {
8539
+ for (const selector of PET_WORKING_SELECTORS) {
8540
+ if (document.querySelector(selector) !== null) return true
8541
+ }
8542
+ } catch {
8543
+ return false
8544
+ }
8545
+ return false
8546
+ }
8547
+
8548
+ /** 生成开始:冒加油台词 + 原地小跳两下(CSS 两次迭代,无 JS 循环)。 */
8549
+ function petCheer() {
8550
+ if (petStage === null || petRun.mode === 'drag') return
8551
+ petNoteInteraction()
8552
+ petBubble(petKindById(petRun.kindId).cheer)
8553
+ petFlash('dsh-pet-cheer', 1700)
8554
+ }
8555
+
8556
+ /** 生成结束(忙过又闲下来):冒完成台词 + 小跳一下。 */
8557
+ function petCelebrate() {
8558
+ if (petStage === null || petRun.mode === 'drag') return
8559
+ petNoteInteraction()
8560
+ petBubble(petKindById(petRun.kindId).done)
8561
+ petFlash('dsh-pet-cheer', 900)
8562
+ }
8563
+
8564
+ /** 在宠物身上挂一个短时类名(挂/摘各一次,句柄入 petTimers,拆台统一清)。 */
8565
+ function petFlash(cls, ms) {
8566
+ if (petRefs.actor === null) return
8567
+ petRefs.actor.classList.add(cls)
8568
+ petTimers.push(window.setTimeout(() => {
8569
+ if (petRefs.actor !== null) petRefs.actor.classList.remove(cls)
8570
+ }, ms))
8571
+ }
8572
+
8573
+ /**
8574
+ * 打字回头:用户在聊天输入框里敲字(节流 15s 内不重复)→ 宠物转身面向输入框,
8575
+ * 精神一下。反应是锦上添花,任何失败都吞掉保持安静。
8576
+ */
8577
+ function petOnTyping(event) {
8578
+ try {
8579
+ if (petStage === null || petRun.mode === 'drag') return
8580
+ const target = event?.target
8581
+ if (target === undefined || target === null) return
8582
+ const isComposer = target.isContentEditable === true || target.tagName === 'TEXTAREA'
8583
+ if (!isComposer) return
8584
+ const now = Date.now()
8585
+ if (now - petLastTypingAt < PET_TYPE_REACT_MS) return
8586
+ petLastTypingAt = now
8587
+ const box = petAnchorBox()
8588
+ if (box !== null && petRefs.actor !== null) {
8589
+ const kind = petKindById(petRun.kindId)
8590
+ const centre = petRun.x + petDisplaySize(kind).w / 2
8591
+ const nextDir = (box.left + box.right) / 2 > centre ? 1 : -1
8592
+ if (nextDir !== petRun.dir) {
8593
+ petRun.dir = nextDir
8594
+ applyPetPositions()
8595
+ }
8596
+ }
8597
+ petFlash('dsh-pet-perk', 900)
8598
+ } catch {
8599
+ // 反应失败不值得打扰任何人。
8600
+ }
8601
+ }
8602
+
8603
+ /* ── 弹窗打开时隐藏挂件(用户 2026-09-27 定)────────────────────────────
8604
+ *
8605
+ * 挂件层挂 body、z-index 1200,高于设置等模态弹窗 —— 弹窗一开,小雪兔就浮在
8606
+ * 弹窗内容上方(实测读数:设置 → 通用设置任何分节都如此)。检测走**语义属性**
8607
+ * (role="dialog" / aria-modal / 原生 <dialog>),绝不碰框架哈希类名(§13);
8608
+ * 任何弹窗都触发 —— 不止设置弹窗,以后别的确认框也不会被挂件挡。
8609
+ * observer 是事件驱动(childList/attributes),随舞台起停 —— 有界;
8610
+ * body 上的标记类只在挂件存活期间存在,拆除时一并清掉。
8611
+ */
8612
+
8613
+ /** 弹窗隐藏的 observer 句柄;undefined = 未安装。 */
8614
+ let petDialogObserver
8615
+
8616
+ /** 现在有没有模态弹窗开着(语义选择器,一次查询)。 */
8617
+ function petDialogOpen() {
8618
+ try {
8619
+ return typeof document !== 'undefined' && document !== null
8620
+ && document.querySelector('dialog[open], [role="dialog"], [aria-modal="true"]') !== null
8621
+ } catch {
8622
+ return false
8623
+ }
8624
+ }
8625
+
8626
+ /**
8627
+ * 把"弹窗开着"落成 body 标记类(PET_CSS 里一条规则据此藏挂件层)。
8628
+ * @returns 弹窗是否开着(诊断读数用)。
8629
+ */
8630
+ function syncPetDialogHidden() {
8631
+ const open = petDialogOpen()
8632
+ try {
8633
+ document.body?.classList?.toggle('dsh-dialog-open', open)
8634
+ } catch {
8635
+ // classList 不可用(极端桩)时挂件照常显示 —— 隐藏是体验优化,不是功能闸。
8636
+ }
8637
+ return open
8638
+ }
8639
+
8640
+ /** 随舞台安装:装上即先同步一次,之后由 DOM 变化驱动。 */
8641
+ function installPetDialogObserver() {
8642
+ if (petDialogObserver !== undefined) return
8643
+ if (typeof MutationObserver !== 'function') return
8644
+ syncPetDialogHidden()
8645
+ petDialogObserver = new MutationObserver(() => { syncPetDialogHidden() })
8646
+ petDialogObserver.observe(document.body, {
8647
+ childList: true, subtree: true,
8648
+ attributes: true, attributeFilter: ['open', 'role', 'aria-modal', 'class'],
8649
+ })
8650
+ }
8651
+
8652
+ /** 随舞台拆除:断开 observer、清掉标记类 —— 挂件不在了就不该留痕迹。 */
8653
+ function removePetDialogObserver() {
8654
+ if (petDialogObserver === undefined) return
8655
+ try { petDialogObserver.disconnect() } catch {
8656
+ // 断开失败只留一个空 observer,下一次安装会覆盖它。
8657
+ }
8658
+ petDialogObserver = undefined
8659
+ try {
8660
+ document.body?.classList?.remove?.('dsh-dialog-open')
8661
+ } catch {
8662
+ // 同上:清理失败不影响功能。
8663
+ }
8664
+ }
8665
+
8666
+ /**
8667
+ * 拆掉舞台与其样式表,清空运行态。
8668
+ *
8669
+ * 定时器、闲聊句柄、document 监听一并清掉:任何一句话都不许对已拆除的节点说。
8670
+ */
8671
+ function removePetStage() {
8672
+ removePetDialogObserver()
8673
+ for (const handle of petTimers) window.clearTimeout(handle)
8674
+ petTimers = []
8675
+ if (typeof window.clearTimeout === 'function') window.clearTimeout(petChatterTimer)
8676
+ petChatterTimer = 0
8677
+ if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
8678
+ document.removeEventListener('input', petOnTyping, true)
8679
+ }
8680
+ if (petStage !== null) petStage.remove()
8681
+ petStage = null
8682
+ for (const key of Object.keys(petRefs)) petRefs[key] = null
8683
+ petTrailCount = 0
8684
+ petBubbleCount = 0
8685
+ petRun.mode = 'idle'
8686
+ petRun.dragging = null
8687
+ petRun.anchorKey = ''
8688
+ petRun.lastAt = 0
8689
+ petRun.kindId = ''
8690
+ petRun.lift = 0
8691
+ petRun.hopFrac = 0
8692
+ petLastInteractAt = 0
8693
+ petLastTypingAt = 0
8694
+ // 注意:petRun.working / petLastWorkedAt / everWorked **不**随舞台重置 ——
8695
+ // "模型在不在生成"是对应用的观察,不是舞台的状态;重置它会让换宠/重搭期间的
8696
+ // 边沿(尤其忙→闲的完成庆祝)静默丢失。陈旧触发由庆祝的 60s 时效窗挡住。
8697
+ }
8698
+
8699
+ /** 把宠物与道具画到它们该在的位置(transform-only,不触发布局;hop 抬升只进显示)。 */
8700
+ function applyPetPositions() {
8701
+ if (petRefs.actor === null || petRefs.prop === null || petRefs.flip === null) return
8702
+ const kind = petKindById(petRun.kindId)
8703
+ petRefs.actor.style.transform = `translate(${Math.round(petRun.x)}px,${Math.round(petRun.y - petRun.lift)}px)`
8704
+ petRefs.prop.style.transform
8705
+ = `translate(${Math.round(petRun.propX - petPropDisplaySize(kind).w / 2)}px,${Math.round(petRun.propY)}px)`
8706
+ // 镜像写在独立的 flip 层 —— 绝不与跑步动画同层(动画会覆盖内联 transform,
8707
+ // 那正是"朝左跑却倒着跑"的事故根因)。
8708
+ petRefs.flip.style.transform = petRun.dir === 1 ? 'none' : 'scaleX(-1)'
8709
+ }
8710
+
8711
+ /**
8712
+ * 跑动类名按步态挂:run → 腿部摆动 + 起伏;waddle → 身体左右摇;
8713
+ * hop 不挂循环类(起伏由运行时驱动,落地节奏即脚步)。
8714
+ */
8715
+ function petMotionClass() {
8716
+ return petKindById(petRun.kindId).motion === 'waddle' ? 'dsh-pet-waddle' : 'dsh-pet-run'
8717
+ }
8718
+
8719
+ /** 开始跑向道具(幂等:跑动中 / 拖拽中 / 已在道具旁都不会白跑一轮)。 */
8720
+ function petStartRun() {
8721
+ if (petStage === null || petRun.mode === 'run' || petRun.mode === 'drag') return
8722
+ if (petDistanceToProp() <= 5) {
8723
+ petRun.mode = 'idle'
8724
+ return
8725
+ }
8726
+ petRun.mode = 'run'
8727
+ petRun.trailDist = 0
8728
+ petRun.hopFrac = 0
8729
+ petRun.lift = 0
8730
+ petRun.lastAt = 0
8731
+ petRefs.actor.classList.add(petMotionClass())
8732
+ // 复用工厂级单帧助手 step():本文件的 requestAnimationFrame 预算因此不加一。
8733
+ step(petTick)
8734
+ }
8735
+
8736
+ /** 宠物(左上角)到道具(中心)的直线距离(显示坐标系)。 */
8737
+ function petDistanceToProp() {
8738
+ const kind = petKindById(petRun.kindId)
8739
+ return Math.hypot(petRun.propX - petRun.x,
8740
+ (petRun.propY + petPropDisplaySize(kind).h / 2) - (petRun.y + petDisplaySize(kind).h / 2))
8741
+ }
8742
+
8743
+ /** 停下(回到待机)。步态类名一并摘掉,腿/摇摆动画随之停止。 */
8744
+ function petStopRun() {
8745
+ if (petRefs.actor !== null) {
8746
+ petRefs.actor.classList.remove('dsh-pet-run')
8747
+ petRefs.actor.classList.remove('dsh-pet-waddle')
8748
+ }
8749
+ petRun.mode = 'idle'
8750
+ petRun.lastAt = 0
8751
+ petRun.lift = 0
8752
+ }
8753
+
8754
+ /**
8755
+ * 跑动的每一帧。只在 mode === 'run' 时自续,停下即断 —— 这条循环有界:
8756
+ * 终点是道具,距离单调收敛,超时(标签页隐藏时 rAF 自动暂停)也不会累积。
8757
+ * 移动是**二维**的:朝道具的直线方向走,头始终朝向它(水平分量定镜像)。
8758
+ * 三种步态在这里分岔:run/waddle 直线推进(后者靠 CSS 摇摆);hop 抛物线蹦跳
8759
+ * (hopFrac 是 0..1 的相位,lift 只影响显示坐标,落地一枚脚印)。
8760
+ */
8761
+ function petTick() {
8762
+ if (petStage === null || petRun.mode !== 'run') return
8763
+ const kind = petKindById(petRun.kindId)
8764
+ const now = Date.now()
8765
+ const dt = petRun.lastAt === 0 ? 0.016 : Math.min((now - petRun.lastAt) / 1000, 0.064)
8766
+ // 防线:任何非有限数进来都立即停车并留痕 —— NaN 若混进位置,宠物会凭空消失,
8767
+ // 而"消失"没有任何报错。宁可停下来让面板/控制台说一句话。
8768
+ if (!Number.isFinite(dt) || !Number.isFinite(petRun.x) || !Number.isFinite(petRun.y)
8769
+ || !Number.isFinite(petRun.propX) || !Number.isFinite(petRun.propY)) {
8770
+ console.error('[theme-gallery] pet run got a non-finite number; stopping the run loop.'
8771
+ + ` dt=${dt} pet=(${petRun.x},${petRun.y}) prop=(${petRun.propX},${petRun.propY})`)
8772
+ petStopRun()
8773
+ return
8774
+ }
8775
+ petRun.lastAt = now
8776
+ const dx = petRun.propX - petRun.x
8777
+ const dy = (petRun.propY + petPropDisplaySize(kind).h / 2) - (petRun.y + petDisplaySize(kind).h / 2)
8778
+ const dist = Math.hypot(dx, dy)
8779
+ if (dist <= 5) {
8780
+ petStopRun()
8781
+ return
8782
+ }
8783
+ // 先调头再跑:水平朝向由水平分量决定(几乎竖直的短途不改变朝向)。
8784
+ if (dx > 1) petRun.dir = 1
8785
+ else if (dx < -1) petRun.dir = -1
8786
+ const pace = Math.min(kind.speed * dt, dist)
8787
+ petRun.x += (dx / dist) * pace
8788
+ petRun.y += (dy / dist) * pace
8789
+ if (kind.motion === 'hop') {
8790
+ // 一跳 = PET_HOP_LEN_PX 的水平路程:相位推进,抛物线抬升,落地留印。
8791
+ petRun.hopFrac += pace / PET_HOP_LEN_PX
8792
+ if (petRun.hopFrac >= 1) {
8793
+ petRun.hopFrac -= 1
8794
+ petRun.trailSide = -petRun.trailSide
8795
+ dropTrail()
8796
+ }
8797
+ petRun.lift = Math.round(Math.sin(Math.PI * Math.min(petRun.hopFrac, 1)) * PET_HOP_LIFT_PX)
8798
+ } else {
8799
+ petRun.lift = 0
8800
+ petRun.trailDist += pace
8801
+ if (petRun.trailDist >= kind.trail.everyPx) {
8802
+ petRun.trailDist = 0
8803
+ petRun.trailSide = -petRun.trailSide
8804
+ dropTrail()
8805
+ }
8806
+ }
8807
+ applyPetPositions()
8808
+ step(petTick)
8809
+ }
8810
+
8811
+ /**
8812
+ * 落一枚脚印(随物种换形状与颜色),落在宠物当前脚下;
8813
+ * 超时由 JS 兜底回收,活节点数有上限。
8814
+ */
8815
+ function dropTrail() {
8816
+ if (petStage === null || petTrailCount >= PET_TRAIL_MAX) return
8817
+ const kind = petKindById(petRun.kindId)
8818
+ const display = petDisplaySize(kind)
8819
+ const mark = document.createElement('span')
8820
+ mark.className = 'dsh-pet-trailmark'
8821
+ mark.innerHTML = `<svg viewBox="0 0 ${kind.trail.w} ${kind.trail.h}" width="${kind.trail.w}"`
8822
+ + ` height="${kind.trail.h}" aria-hidden="true">${kind.trail.art}</svg>`
8823
+ mark.style.transform = `translate(${Math.round(petRun.x + display.w / 2 + kind.trail.side * petRun.trailSide) - kind.trail.w / 2}px,`
8824
+ + `${Math.round(petRun.y + display.h) - kind.trail.h + 1}px)`
8825
+ petStage.append(mark)
8826
+ petTrailCount += 1
8827
+ window.setTimeout(() => {
8828
+ mark.remove()
8829
+ petTrailCount = Math.max(0, petTrailCount - 1)
8830
+ }, PET_TRAIL_FADE_MS + 200)
8831
+ }
8832
+
8833
+ /** 冒一句气泡(叫声/闲聊/加油共用;文案是常量;数量有上限)。 */
8834
+ function petBubble(text) {
8835
+ if (petStage === null || petBubbleCount >= PET_BUBBLE_MAX) return
8836
+ const kind = petKindById(petRun.kindId)
8837
+ const bubble = document.createElement('span')
8838
+ bubble.className = 'dsh-pet-bubble'
8839
+ bubble.textContent = String(text)
8840
+ bubble.style.transform = `translate(${Math.round(petRun.x) + Math.round(petDisplaySize(kind).w) + 6}px,`
8841
+ + `${Math.round(petRun.y) - 30}px)`
8842
+ petStage.append(bubble)
8843
+ petBubbleCount += 1
8844
+ window.setTimeout(() => {
8845
+ bubble.remove()
8846
+ petBubbleCount = Math.max(0, petBubbleCount - 1)
8847
+ }, 1250)
8848
+ }
8849
+
8850
+ /**
8851
+ * 戳一下:宠物转过身面对用户,说两句它的口头禅,然后该干嘛干嘛(刚才在跑就接着跑)。
8852
+ * react 期间再戳是空操作 —— 不然定时器会互相叠。
8853
+ */
8854
+ function petReact() {
8855
+ if (petStage === null || petRun.mode === 'drag' || petRun.mode === 'react') return
8856
+ const kind = petKindById(petRun.kindId)
8857
+ petNoteInteraction()
8858
+ petRun.reactWasRunning = petRun.mode === 'run'
8859
+ petStopRun()
8860
+ petRun.mode = 'react'
8861
+ petRefs.actor.classList.add('dsh-pet-react')
8862
+ petBubble(kind.react.lines[0])
8863
+ petTimers.push(window.setTimeout(() => petBubble(kind.react.lines[1]), Math.round(kind.react.ms * 0.4)))
8864
+ petTimers.push(window.setTimeout(() => {
8865
+ // react 不可重入(mode 门在函数开头),所以此刻挂起的定时器只有这一组:
8866
+ // 直接清空即可,数组不会随戳的次数增长。
8867
+ petTimers = []
8868
+ if (petRefs.actor === null) return
8869
+ petRefs.actor.classList.remove('dsh-pet-react')
8870
+ petRun.mode = 'idle'
8871
+ if (petRun.reactWasRunning || petDistanceToProp() > 5) petStartRun()
8872
+ }, kind.react.ms))
8873
+ }
8874
+
8875
+ /** 点「改名」:名牌切换成输入框并钉住显示(不依赖悬停)。 */
8876
+ function petStartRename() {
8877
+ if (petRefs.tag === null || petRefs.input === null) return
8878
+ const kind = petKindById(petRun.kindId)
8879
+ petRefs.tag.classList.add('dsh-pet-editing')
8880
+ petRefs.input.value = readPetState().names[kind.id] ?? kind.home
8881
+ try {
8882
+ petRefs.input.focus()
8883
+ petRefs.input.select()
8884
+ } catch {
8885
+ // 无焦点可给(极端环境)就跳过;输入框仍在,点击即得焦点。
8886
+ }
8887
+ }
8888
+
8889
+ /** 提交改名:空值回退旧名,最长 10 字;textContent 写回,注入面为零;名字按宠记忆。 */
8890
+ function petCommitRename() {
8891
+ if (petRefs.tag === null || petRefs.input === null) return
8892
+ if (!petRefs.tag.classList.contains('dsh-pet-editing')) return
8893
+ petRefs.tag.classList.remove('dsh-pet-editing')
8894
+ const kind = petKindById(petRun.kindId)
8895
+ const state = readPetState()
8896
+ const current = state.names[kind.id] ?? kind.home
8897
+ const raw = String(petRefs.input.value ?? '').trim().slice(0, 10)
8898
+ const name = raw === '' ? current : raw
8899
+ savePetState({ names: { ...state.names, [kind.id]: name } })
8900
+ if (petRefs.name !== null) petRefs.name.textContent = name
8901
+ petNoteInteraction()
8902
+ }
8903
+
8904
+ /** 取消改名(Esc)。 */
8905
+ function petCancelRename() {
8906
+ if (petRefs.tag !== null) petRefs.tag.classList.remove('dsh-pet-editing')
8907
+ }
8908
+
8909
+ /**
8910
+ * 开始拖宠物 / 拖道具。指针捕获尽量用上(指针出了窗口也能收到 pointerup),
8911
+ * 事件监听挂在被捕获元素上、结束时成对摘除 —— 不给全局留下常驻监听。
8912
+ * @param event - pointerdown 事件(桩环境可能缺字段,逐项容错)。
8913
+ * @param which - 'actor' 或 'prop'。
8914
+ */
8915
+ function petDragStart(event, which) {
8916
+ try {
8917
+ if (petStage === null || petRun.mode === 'react') return
8918
+ const point = petPointOf(event)
8919
+ if (point === null) return
8920
+ const target = event.currentTarget
8921
+ if (target === undefined || target === null) return
8922
+ // 先停跑、再进拖拽态 —— 顺序不能反:petStopRun 会把 mode 写回 idle,
8923
+ // 而宠物移动的每一帧都靠 mode === 'drag' 才跟手(反着写,拖动就静默失效)。
8924
+ petStopRun()
8925
+ petRun.mode = 'drag'
8926
+ petRun.dragging = which
8927
+ petRun.dragDX = point.x - (which === 'actor' ? petRun.x : petRun.propX)
8928
+ petRun.dragDY = point.y - (which === 'actor' ? petRun.y : petRun.propY)
8929
+ petStage.classList.add('dsh-pet-dragging')
8930
+ if (typeof target.setPointerCapture === 'function' && typeof event.pointerId === 'number') {
8931
+ try { target.setPointerCapture(event.pointerId) } catch { /* 指针可能已经释放 */ }
8932
+ }
8933
+ const move = (moveEvent) => petDragMove(moveEvent)
8934
+ const end = (endEvent) => {
8935
+ target.removeEventListener('pointermove', move)
8936
+ target.removeEventListener('pointerup', end)
8937
+ target.removeEventListener('pointercancel', end)
8938
+ petDragEnd(endEvent)
8939
+ }
8940
+ target.addEventListener('pointermove', move)
8941
+ target.addEventListener('pointerup', end)
8942
+ target.addEventListener('pointercancel', end)
8943
+ } catch (error) {
8944
+ console.error('[theme-gallery] pet drag could not start:', error)
8945
+ }
8946
+ }
8947
+
8948
+ /** 拖动中:全屏二维跟手(x / y 都自由,钳位只保证不出屏)。 */
8949
+ function petDragMove(event) {
8950
+ if (petRun.mode !== 'drag') return
8951
+ const point = petPointOf(event)
8952
+ if (point === null) return
8953
+ const viewport = petViewport()
8954
+ const kind = petKindById(petRun.kindId)
8955
+ const display = petDisplaySize(kind)
8956
+ const propDisplay = petPropDisplaySize(kind)
8957
+ if (petRun.dragging === 'actor') {
8958
+ // 宠物的 transform 用左上角坐标,钳位按左上角算(右/下各留出它的宽高)。
8959
+ petRun.x = petClampX(point.x - petRun.dragDX, 0, viewport.width - display.w)
8960
+ petRun.y = petClampX(point.y - petRun.dragDY, 0, viewport.height - display.h)
8961
+ } else {
8962
+ petRun.propX = petClampX(point.x - petRun.dragDX, propDisplay.w / 2, viewport.width)
8963
+ petRun.propY = petClampX(point.y - petRun.dragDY, 0, viewport.height - propDisplay.h)
8964
+ }
8965
+ applyPetPositions()
8966
+ }
8967
+
8968
+ /**
8969
+ * 松手:记住新位置(视口相对偏移,重启/缩放后自愈),然后宠物跑向道具
8970
+ * (拖道具同理 —— 道具到哪它追到哪,直线跑过去)。
8971
+ */
8972
+ function petDragEnd() {
8973
+ if (petStage === null) return
8974
+ petStage.classList.remove('dsh-pet-dragging')
8975
+ const which = petRun.dragging
8976
+ petRun.dragging = null
8977
+ petRun.mode = 'idle'
8978
+ const viewport = petViewport()
8979
+ if (which === 'actor') {
8980
+ savePetState({
8981
+ placed: true,
8982
+ pet: { dx: Math.round(petRun.x - viewport.width), dy: Math.round(petRun.y - viewport.height) },
8983
+ })
8984
+ } else if (which === 'prop') {
8985
+ savePetState({
8986
+ placed: true,
8987
+ prop: { dx: Math.round(petRun.propX - viewport.width), dy: Math.round(petRun.propY - viewport.height) },
8988
+ })
8989
+ }
8990
+ petNoteInteraction()
8991
+ applyPetPositions()
8992
+ petStartRun()
8993
+ }
8994
+
8995
+ /**
8996
+ * 搭建舞台。结构节点全部用 createElement 逐个搭建(事件挂得上、桩环境找得到),
8997
+ * 美术件按注册表包裹注入(petSvg/petPropSvg);名字用 textContent 写入 ——
8998
+ * 用户输入永远不进 HTML 字符串。尺寸(每只宠物不同)写内联,CSS 只管布局。
8999
+ */
9000
+ function buildPetStage() {
9001
+ const state = readPetState()
9002
+ const kind = petKindById(state.kind)
9003
+ const display = petDisplaySize(kind)
9004
+ const propDisplay = petPropDisplaySize(kind)
9005
+ const stage = document.createElement('div')
9006
+ stage.id = PET_STAGE_ID
9007
+ stage.dataset.plugin = 'theme-gallery'
9008
+ stage.dataset.petOwner = PET_OWNER
9009
+ stage.dataset.petKind = kind.id
9010
+
9011
+ const prop = document.createElement('div')
9012
+ prop.className = 'dsh-pet-prop'
9013
+ prop.style.width = `${propDisplay.w}px`
9014
+ prop.style.height = `${propDisplay.h}px`
9015
+ prop.innerHTML = petPropSvg(kind)
9016
+
9017
+ const actor = document.createElement('div')
9018
+ actor.className = 'dsh-pet-actor'
9019
+ actor.style.width = `${display.w}px`
9020
+ actor.style.height = `${display.h}px`
9021
+
9022
+ // flip 层(水平镜像,内联 transform)与 figure 层(跑步/跳跃动画)必须分开 ——
9023
+ // 同层时 CSS 动画覆盖内联 transform,宠物会朝左跑却头朝右(真机事故)。
9024
+ const flip = document.createElement('span')
9025
+ flip.className = 'dsh-pet-flip'
9026
+ const figure = document.createElement('span')
9027
+ figure.className = 'dsh-pet-figure'
9028
+ figure.innerHTML = petSvg(kind, 'side', 'dsh-pet-side') + petSvg(kind, 'front', 'dsh-pet-front')
9029
+ flip.append(figure)
9030
+
9031
+ const tag = document.createElement('span')
9032
+ tag.className = 'dsh-pet-tag'
9033
+ const name = document.createElement('span')
9034
+ name.className = 'dsh-pet-name'
9035
+ const actions = document.createElement('span')
9036
+ actions.className = 'dsh-pet-actions'
9037
+ const renameButton = document.createElement('button')
9038
+ renameButton.type = 'button'
9039
+ renameButton.className = 'dsh-pet-rename'
9040
+ renameButton.textContent = '改名'
9041
+ const pokeButton = document.createElement('button')
9042
+ pokeButton.type = 'button'
9043
+ pokeButton.className = 'dsh-pet-poke'
9044
+ pokeButton.textContent = '戳一下'
9045
+ const input = document.createElement('input')
9046
+ input.className = 'dsh-pet-input'
9047
+ input.maxLength = 10
9048
+ input.setAttribute('aria-label', `${kind.home}的名字`)
9049
+ actions.append(renameButton, pokeButton)
9050
+ tag.append(name, actions, input)
9051
+ actor.append(tag, flip)
9052
+ stage.append(prop, actor)
9053
+
9054
+ petRefs.actor = actor
9055
+ petRefs.prop = prop
9056
+ petRefs.flip = flip
9057
+ petRefs.figure = figure
9058
+ petRefs.tag = tag
9059
+ petRefs.name = name
9060
+ petRefs.input = input
9061
+ petRefs.actor.addEventListener('pointerdown', (event) => petDragStart(event, 'actor'))
9062
+ petRefs.prop.addEventListener('pointerdown', (event) => petDragStart(event, 'prop'))
9063
+ // 名牌里的按钮不许触发拖拽:按住"戳一下"把宠物拖着走,谁都会觉得坏了。
9064
+ for (const control of [renameButton, pokeButton, input]) {
9065
+ control.addEventListener('pointerdown', (event) => {
9066
+ if (event !== undefined && typeof event.stopPropagation === 'function') event.stopPropagation()
9067
+ })
9068
+ }
9069
+ renameButton.addEventListener('click', () => petStartRename())
9070
+ pokeButton.addEventListener('click', () => petReact())
9071
+ input.addEventListener('keydown', (event) => {
9072
+ if (event?.key === 'Enter') petCommitRename()
9073
+ else if (event?.key === 'Escape') petCancelRename()
9074
+ })
9075
+ input.addEventListener('blur', () => petCommitRename())
9076
+ name.textContent = state.names[kind.id] ?? kind.home
9077
+ petRun.kindId = kind.id
9078
+
9079
+ petStage = stage
9080
+ document.body.append(stage)
9081
+ // 活性机制随舞台一起起停:打字监听装上、闲聊排一次(时间闸见 petChatterTick)。
9082
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
9083
+ document.addEventListener('input', petOnTyping, true)
9084
+ }
9085
+ // 弹窗隐藏挂件的 observer 同步起停(装上即先按当前状态同步一次)。
9086
+ installPetDialogObserver()
9087
+ schedulePetChatter()
9088
+ }
9089
+
9090
+ /** 最近一次挂件同步的读数(面板诊断用,与 ambientReport 同一角色)。 */
9091
+ let petReport
9092
+
9093
+ /**
9094
+ * 把最近一次同步的读数写进 petReport。
9095
+ * @param entry - 读数片段(enabled / stage / display / anchor / 几何 / css / note)。
9096
+ */
9097
+ function notePetReport(entry) {
9098
+ petReport = { ...entry }
9099
+ }
9100
+
9101
+ /** 面板上的挂件读数,一句话(与 describeAmbientReport 同一风格,刻意笨)。 */
9102
+ function describePetReport() {
9103
+ const r = petReport
9104
+ if (r === undefined) return '挂件[尚未同步]'
9105
+ const parts = [r.enabled === true ? '开' : '关', `舞台=${r.stage ?? '?'}`, `显示=${r.display === true ? '在' : '收'}`,
9106
+ `锚点=${r.anchor ?? '?'}`, `品种=${r.kind ?? '-'}`, `活动=${r.activity ?? '-'}`]
9107
+ if (r.right !== undefined) parts.push(`锚右=${r.right}`, `锚底=${r.bottom}`)
9108
+ // 地面= 独立输出:视口回退(锚点断)时恰恰最需要这个读数来核对抬量 ——
9109
+ // 旧实现把它挂在 锚右 那一支里,而 right 只在锚点找到时才写,回退时地面
9110
+ // 读数跟着一起消失(读数通道静默断裂,实机核对无从做起)。
9111
+ if (r.groundY !== undefined) parts.push(`地面=${r.groundY}`)
9112
+ if (r.x !== undefined) parts.push(`宠x=${r.x}`, `宠y=${r.y ?? '?'}`, `道具x=${r.propX}`, `道具y=${r.propY ?? '?'}`)
9113
+ parts.push(`样式=${r.css === true ? '在' : '缺'}`, `模式=${r.mode ?? '-'}`)
9114
+ if (r.note !== undefined) parts.push(r.note)
9115
+ return `挂件[${parts.join(' ')}]`
9116
+ }
9117
+
9118
+ /**
9119
+ * 面板上的挂件自检行。
9120
+ *
9121
+ * 挂件关闭时不产出行(不打扰);开启时的**警告**(舞台没建出 / 被收起 / 样式缺失)
9122
+ * 必须无条件到达面板 —— "开了但什么都不出现"正是本项目最典型的静默失败,
9123
+ * 这一行就是它的可观测出口。
9124
+ * @param petEnabled - store 里的挂件开关。
9125
+ * @returns 一行自检文字,或 null(挂件关闭)。
9126
+ */
9127
+ function petLineOf(petEnabled) {
9128
+ try {
9129
+ if (petEnabled !== true) return null
9130
+ const r = petReport
9131
+ if (r === undefined || r.enabled !== true) return '⚠ 挂件已开启但尚未同步(syncPetStage 未运行)'
9132
+ if (r.stage === 'absent') return `⚠ 挂件未建出舞台:${r.note ?? '锚点(输入框)未找到'}`
9133
+ if (r.display !== true) return `⚠ 挂件舞台已建但被收起:锚点=${r.anchor}`
9134
+ if (r.css !== true) return '⚠ 挂件样式表未生效(PET_CSS 不在文档中)'
9135
+ return `挂件自检通过 · ${describePetReport()}`
9136
+ } catch (error) {
9137
+ return `⚠ 挂件自检失败: ${String(error && error.message ? error.message : error)}`
9138
+ }
9139
+ }
9140
+
9141
+ /** 与 sceneryLineIsWarning 同一条判据:⚠ 开头或带"失败"的行不算例行汇报。 */
9142
+ function petLineIsWarning(line) {
9143
+ return typeof line === 'string' && (line.startsWith('⚠') || line.includes('失败'))
9144
+ }
9145
+
9146
+ /**
9147
+ * 挂件层的同步入口:resync(每帧至多一次,与 ambient 共用节流)与开关切换都会进来。
9148
+ *
9149
+ * 幂等判据在最前面(规则 3):几何指纹没变就一个字节都不写。输入框不在屏幕上
9150
+ * (比如正看着别的面板)时挂件收起,等它回来 —— 收起不算状态变化。
9151
+ * 每一条退出路径都写 petReport:挂件的失败形态就是"什么都不出现",没有读数就永远查不下去。
9152
+ *
9153
+ * 二期新增两件事,都在一切写入之前完成判定:
9154
+ * · **换宠物**:localStorage 里的 kind 与舞台当前的 kindId 不一致 → 拆旧舞台、
9155
+ * 按新宠物重建(缺省摆位 + 出场跑向道具由既有逻辑自然发生)。
9156
+ * · **生成中信号**(petWorkingSignal)在这里顺路检测 —— resync 本来就随 DOM
9157
+ * 变化节流触发,不需要新观察器;上升沿加油、下降沿(忙过之后)庆祝。
9158
+ */
9159
+ function syncPetStage() {
9160
+ if (typeof document === 'undefined') return
9161
+ // 急停开关与 ambient 同源:config.ambient:false 把这层装饰一并停掉。
9162
+ if (!petRun.enabled || !ambientEnabled()) {
9163
+ if (petStage !== null) removePetStage()
9164
+ notePetReport({ enabled: petRun.enabled, stage: 'absent', display: false, 'anchor': '-', note: ambientEnabled() ? '开关为关' : '急停开关(ambient:false)' })
9165
+ return
9166
+ }
9167
+ const state = readPetState()
9168
+ const kind = petKindById(state.kind)
9169
+ const viewport = petViewport()
9170
+ if (petRun.firstSyncAt === 0) petRun.firstSyncAt = Date.now()
9171
+ // 地面线:锚点(输入框)找到时贴它的下缘;找不到回退到屏幕底缘**抬高一段**
9172
+ // (实机验收:主题面板页 composer 隐藏 → 视口回退 → 宠物贴着窗口最底边被裁掉
9173
+ // 一截。抬高 48px 让它完整可见;输入框在屏上时本行一个像素都不变)。
9174
+ // 自检行有 锚点=视口回退 与 地面= 两个读数,实机可直接核对回退边距。
9175
+ const anchor = petAnchorBox()
9176
+ const anchorKind = anchor === null ? '视口回退' : 'found'
9177
+ const anchorRead = anchor === null ? {} : { right: Math.round(anchor.right), bottom: Math.round(anchor.bottom) }
9178
+ const groundTop = anchor === null ? viewport.height - PET_FALLBACK_GROUND_GAP : anchor.bottom
9179
+ petRun.groundY = Math.min(groundTop - 2, viewport.height - 6)
9180
+ ensurePetStylesheet()
9181
+ // 换宠物:不是当前舞台这只 → 拆掉重建(removePetStage 会清干净定时器与监听)。
9182
+ if (petStage !== null && petRun.kindId !== kind.id) removePetStage()
9183
+ // 热重载会把上一个实例的舞台留在 body 上:不是本实例属主的直接清扫(ambient 同款)。
9184
+ for (const stray of document.querySelectorAll(`#${PET_STAGE_ID}`)) {
9185
+ if (stray.dataset.petOwner !== PET_OWNER) stray.remove()
9186
+ }
9187
+ if (petStage === null) buildPetStage()
9188
+ if (petStage === null) {
9189
+ notePetReport({ enabled: true, stage: 'absent', display: false, anchor: anchorKind, note: '舞台搭建失败(见控制台)' })
9190
+ return
9191
+ }
9192
+ petStage.style.display = ''
9193
+ // 生成中信号:边缘检测(闲→忙加油;忙→闲且忙过才庆祝)。放在舞台建成**之后**:
9194
+ // 边沿若恰好落在"舞台刚被拆掉待重建"的那次同步里(比如换宠物),在前面检测就会
9195
+ // 静默丢失这次加油。任何检测失败都按"闲"处理,静默降级。
9196
+ const working = petWorkingSignal()
9197
+ if (working !== petRun.working) {
9198
+ petRun.working = working
9199
+ if (working) petCheer()
9200
+ // 60s 时效窗:忙过的记录太旧(比如挂件关了一阵再开)就不庆祝,
9201
+ // 那不是"刚完成的对话"。
9202
+ else if (petLastWorkedAt !== 0 && Date.now() - petLastWorkedAt < 60000) petCelebrate()
9203
+ }
9204
+ if (working) {
9205
+ petRun.everWorked = true
9206
+ petLastWorkedAt = Date.now()
9207
+ } else {
9208
+ petLastWorkedAt = 0
9209
+ }
9210
+ const key = [
9211
+ Math.round(viewport.width), Math.round(viewport.height),
9212
+ Math.round(petRun.groundY), state.placed === true, kind.id,
9213
+ ].join('|')
9214
+ const shownNow = petStage.style.display !== 'none'
9215
+ const cssReady = ambientStylesheetReady()
9216
+ && document.querySelector('style[data-plugin-css="theme-gallery/pet"]') !== null
9217
+ const activity = petRun.everWorked === false && Date.now() - petRun.firstSyncAt > 300000
9218
+ ? '未检出'
9219
+ : (petRun.working ? '生成中' : '闲')
9220
+ const kindNote = stateKindFallback ? '注=kind回落' : undefined
9221
+ const withNote = (base) => [base, kindNote].filter(Boolean).join(' ')
9222
+ if (key === petRun.anchorKey) {
9223
+ notePetReport({
9224
+ enabled: true, stage: 'built', display: shownNow, anchor: anchorKind,
9225
+ kind: kind.id, activity, ...anchorRead,
9226
+ groundY: Math.round(petRun.groundY),
9227
+ x: Math.round(petRun.x), y: Math.round(petRun.y),
9228
+ propX: Math.round(petRun.propX), propY: Math.round(petRun.propY),
9229
+ css: cssReady, mode: petRun.mode, note: withNote('几何未变化'),
9230
+ })
9231
+ return
9232
+ }
9233
+ petRun.anchorKey = key
9234
+ // 拖拽中位置归指针管:几何变化(如拖动的同时改了窗口)等松手后再算,
9235
+ // 否则这里改写位置会和指针打架,松手时还会存下错的偏移。
9236
+ if (petRun.mode === 'drag') {
9237
+ notePetReport({
9238
+ enabled: true, stage: 'built', display: true, anchor: anchorKind,
9239
+ kind: kind.id, activity, ...anchorRead,
9240
+ groundY: Math.round(petRun.groundY), css: cssReady, mode: petRun.mode, note: withNote('拖拽中:位置归指针管'),
9241
+ })
9242
+ return
9243
+ }
9244
+ if (state.placed) {
9245
+ // 用户拖过:以视口右/下缘的偏移还原(窗口缩放后自愈),只保证不出屏。
9246
+ const display = petDisplaySize(kind)
9247
+ const propDisplay = petPropDisplaySize(kind)
9248
+ petRun.propX = petClampX(viewport.width + state.prop.dx, propDisplay.w / 2, viewport.width)
9249
+ petRun.propY = petClampX(viewport.height + state.prop.dy, 0, viewport.height - propDisplay.h)
9250
+ if (petRun.mode === 'idle') {
9251
+ petRun.x = petClampX(viewport.width + state.pet.dx, 0, viewport.width - display.w)
9252
+ petRun.y = petClampX(viewport.height + state.pet.dy, 0, viewport.height - display.h)
9253
+ }
9254
+ } else {
9255
+ // 缺省摆位(每只宠物自带):道具在宠物**左侧**、宠物**面朝左**看着玩具,
9256
+ // 两者都站在地面线上;水平位置锚定屏幕右缘(输入框右侧的空白区域)。
9257
+ const display = petDisplaySize(kind)
9258
+ const propDisplay = petPropDisplaySize(kind)
9259
+ petRun.propX = petClampX(viewport.width + kind.place.prop.dx, propDisplay.w / 2, viewport.width)
9260
+ petRun.propY = petRun.groundY - propDisplay.h
9261
+ if (petRun.mode === 'idle') {
9262
+ petRun.x = petClampX(viewport.width + kind.place.pet.dx, 0, viewport.width - display.w)
9263
+ petRun.y = petRun.groundY - display.h
9264
+ }
9265
+ }
9266
+ applyPetPositions()
9267
+ // 开局(或几何变化后)离道具远就跑过去 —— 也是它每次启用的小入场。
9268
+ if (petRun.mode === 'idle' && petDistanceToProp() > 6) petStartRun()
9269
+ notePetReport({
9270
+ enabled: true, stage: 'built', display: true, anchor: anchorKind,
9271
+ kind: kind.id, activity, ...anchorRead,
9272
+ groundY: Math.round(petRun.groundY),
9273
+ x: Math.round(petRun.x), y: Math.round(petRun.y),
9274
+ propX: Math.round(petRun.propX), propY: Math.round(petRun.propY),
9275
+ css: cssReady, mode: petRun.mode, note: kindNote,
9276
+ })
9277
+ }
9278
+
9279
+ /**
9280
+ * Report whether the accent token layer actually reached the theme snapshot.
9281
+ *
9282
+ * ── WHY THIS READING IS NEEDED ───────────────────────────────────────────
9283
+ *
9284
+ * `syncAccent` stacks three tokens (`button-ghost-active-fill` / `-border` / `-hover`) with
9285
+ * `ctx.theme.overrideTokens`. Whether that layer works had **never been verified**, and one
9286
+ * earlier attempt to verify it used the wrong evidence: the active workspace FOLDER icon
9287
+ * turns the accent colour, which was taken as proof — but that icon is painted by the skin's
9288
+ * own `--dsw-alias-state-business-primary` token and has nothing to do with the layer.
9289
+ *
9290
+ * The service makes this checkable without any guesswork: `composeActive` folds every
9291
+ * override layer into `snapshot.active.tokens` before publishing, so the composed value is
9292
+ * readable straight off `getTheme()`.
9293
+ *
9294
+ * ── WHAT CAN AND CANNOT PROVE IT ─────────────────────────────────────────
9295
+ *
9296
+ * The composed VALUE is the only channel that carries information, and only when it differs
9297
+ * from what the skin declares for that same token:
9298
+ *
9299
+ * • both bundled skins already ship `button-ghost-active-*` in their own palettes, AND the
9300
+ * palette layer supplies them too — so the token COUNT is 67 either way. An earlier
9301
+ * comment here claimed a count that "does not move" disproves the layer; for these skins
9302
+ * it cannot move, so the claim was wrong and the count is reported for context only;
9303
+ * • 梦海游鱼 declares `#2B74B5` for that token but its accent is `#FFD166`, so reading back
9304
+ * `#FFD166` proves the accent layer composed OVER the palette — the palette alone cannot
9305
+ * produce it;
9306
+ * • 山青婷彩's accent is `#E88BB0` and it declares `#E88BB0` for the same token, so its
9307
+ * reading is identical whether or not the layer is there. That is a property of the SKIN,
9308
+ * not of the layer, and the verdict below says so instead of implying the reading proved
9309
+ * something.
9310
+ *
9311
+ * The verdict is therefore computed, never asserted: it compares the composed value with the
9312
+ * skin's declared value and with the accent the layer was built from.
9313
+ * @returns the reading, as one segment of the panel line.
9314
+ */
9315
+ function describeAccentLayer() {
9316
+ try {
9317
+ const theme = ctx?.theme
9318
+ if (theme === undefined) return '激活层[无 theme 服务]'
9319
+ const snapshot = theme.getTheme()
9320
+ const active = snapshot?.active
9321
+ const tokens = active?.tokens ?? {}
9322
+ const TOKEN = '--dsw-alias-button-ghost-active-border'
9323
+ const composed = tokens[TOKEN]
9324
+ const definition = typeof active?.id === 'string' ? bundledTheme(active.id) : undefined
9325
+ const declared = declaredToken(definition, TOKEN)
9326
+ const accent = definition?.accent
9327
+ const layers = snapshot?.overrides === undefined
9328
+ ? '(服务未暴露)'
9329
+ : String(snapshot.overrides.size ?? snapshot.overrides.length ?? '?')
9330
+ // Computed, not assumed — see the note above. `不可分辨` is a real answer: it says this
9331
+ // skin's own value coincides with its accent, so this reading proves nothing either way.
9332
+ const verdict = accent === undefined
9333
+ ? '非本包皮肤(无法判断)'
9334
+ : accent === declared
9335
+ ? '不可分辨(强调色=皮肤自备值)'
9336
+ : composed === accent
9337
+ ? '生效(强调色压过自备值)'
9338
+ : composed === declared
9339
+ ? '未生效(仍是自备值)'
9340
+ : `未知(既非强调色也非自备值)`
9341
+ return `激活层[id=${active?.id ?? '?'}`
9342
+ + ` 层数=${layers}`
9343
+ + ` token数=${Object.keys(tokens).length}(自备同名令牌,不构成判据)`
9344
+ + ` ${TOKEN.replace('--dsw-alias-', '')}=${composed === undefined ? '(缺失)' : String(composed)}`
9345
+ + ` 自备=${declared === undefined ? '(无)' : String(declared)}`
9346
+ + ` 强调色=${accent ?? '(无)'}`
9347
+ + ` 判据=${verdict}]`
9348
+ } catch (error) {
9349
+ return `激活层[读取失败: ${String(error && error.message ? error.message : error)}]`
9350
+ }
9351
+ }
9352
+
9353
+ /* ═══════════════════ 心情问候语(标题栏跑马灯)═══════════════════
9354
+ *
9355
+ * 面板第 14 张卡「心情问候」控制的第三种非主题卡面(与宠物挂件同一模式):
9356
+ * 一条滚动的问候文字住在标题栏条内(应用/编辑菜单右侧、原生窗口按钮左侧的
9357
+ * 空拖拽带上),每 35 分钟换一条,语言料库里的正向短句。需求与决策记录见
9358
+ * docs/DESIGN-心情问候语-位置与语料方案.md,实施细节见 IMP-心情问候语-A/B/C。
9359
+ *
9360
+ * · 不写主题服务:开关、设置、选句指针全部存 localStorage,绝不碰 preference;
9361
+ * · 文字**只经 textContent** 进 DOM —— 语料虽是自家数据,注入面也按零算;
9362
+ * · 有界工作:一个链式 setTimeout(35 分钟换条)+ 一个 resize listener,
9363
+ * 没有轮询、没有 interval(自旋事故的家规);
9364
+ * · 座位是 shell.overlay 槽位里的一张静态 div(见 mountGallery 的槽位注册段),
9365
+ * 本模块命令式地往里建条;座位还没渲染时留读数等下一次 sync,不轮询。
9366
+ */
9367
+
9368
+ /** 卡片数据:id、标题与 tooltip(卡面**不渲染** description,与配色/挂件卡同一条分支)。 */
9369
+ const MOOD_WIDGET = {
9370
+ id: 'mood-greeting',
9371
+ label: '心情问候',
9372
+ description: '标题栏上滚动的问候语:默认开启,35 分钟换一条。卡上可开关,'
9373
+ + '并可调滚动速度(慢/中/快)、方向(左→右/右→左)与位置区域'
9374
+ + '(居中/左靠齐/左右缩进/右靠齐)。它不改变当前主题皮肤,可与任何皮肤同时开启。',
9375
+ }
9376
+
9377
+ /** 问候的开关与三组设置记在这里。挂件同一条铁律:绝不写 `ui-theme.preference`。 */
9378
+ const MOOD_KEY = 'theme-gallery:mood'
9379
+
9380
+ /** 选句指针(当天消费到第几条)单独存:形状与用户设置无关,坏了自己愈合。 */
9381
+ const MOOD_POINTER_KEY = 'theme-gallery:mood-pointer'
9382
+
9383
+ /** 换条周期(用户定的 35 分钟)。 */
9384
+ const MOOD_PERIOD_MS = 35 * 60 * 1000
9385
+
9386
+ /** 滚动速度三档(px/s)。中速默认;数值是初值,实机看着不合适就调表——
9387
+ * 调了要同步改 check-mood 的常量断言。60 偏快(用户二轮返工),中速降到 50。 */
9388
+ const MOOD_SPEEDS = { slow: 30, medium: 50, fast: 100 }
9389
+
9390
+ /**
9391
+ * 位置区域的 5 等份映射(用户原话的几何化,DESIGN 第九节):
9392
+ * 区域 = 标题栏条减去左侧菜单后的水平段分成 5 等份,每一档占用其中几份。
9393
+ * 留空的等份是给"将来装的其他插件"的(用户原话)。
9394
+ */
9395
+ const MOOD_ZONE_GEOMETRY = {
9396
+ left: { first: 1, parts: 4 }, // 左靠齐:占第 1–4 等份,右侧留 1
9397
+ center: { first: 1, parts: 5 }, // 居中(默认):占满
9398
+ indent: { first: 2, parts: 3 }, // 左右缩进:中间 3 等份,左右各留 1
9399
+ right: { first: 2, parts: 4 }, // 右靠齐:占第 2–5 等份,左侧留 1
9400
+ }
9401
+
9402
+ /**
9403
+ * 时段池边界(已确认):早安 05:00–11:00 / 白天 11:00–22:00 / 夜深 22:00–次日 05:00。
9404
+ * 夜深的 `to: 29` 是环形写法:小时数 <5 时先加 24(见 moodPoolName),24–28 落进夜深。
9405
+ */
9406
+ const MOOD_TIME_POOLS = [
9407
+ { pool: 'morning', from: 5, to: 11 },
9408
+ { pool: 'day', from: 11, to: 22 },
9409
+ { pool: 'night', from: 22, to: 29 },
9410
+ ]
9411
+
9412
+ /**
9413
+ * 原生窗口控制按钮(最小化/最大化/关闭)的预留宽度。它们是 OS 层 overlay,
9414
+ * DOM 测不到,只能预留——135px 是按钮本身,**用户返工(2026-09-27 截图)**:
9415
+ * 文字要离右上角按钮"远一点",所以再让出 45px 呼吸位,共 180。
9416
+ */
9417
+ const MOOD_WINBTN_RESERVE = 180
9418
+
9419
+ /**
9420
+ * 左端让位(用户二轮返工):侧栏分界线到问候文字的呼吸位;菜单右缘的间隙
9421
+ * (侧栏收起时才由菜单决定左端,此时多让一些)。兜底常量 = 展开时侧栏 280 + 呼吸位。
9422
+ */
9423
+ const MOOD_SIDEBAR_GAP = 12
9424
+ const MOOD_MENU_GAP = 48
9425
+ const MOOD_FALLBACK_MENU_RIGHT = 292
9426
+
9427
+ /** shell.overlay 里那张静态座位的选择器(槽位注册段渲染),与问候条元素 id。 */
9428
+ const MOOD_SEAT_SELECTOR = '[data-dsh-mood-seat]'
9429
+ const MOOD_BAR_ID = 'dsh-mood-bar'
9430
+
9431
+ /** 语料分类枚举——与 embed-themes.mjs 的 MOOD_CATEGORIES 同一张表(建期已校验,运行期再认一遍)。 */
9432
+ const MOOD_CATEGORIES = ['morning', 'day', 'night', 'general', 'holiday']
9433
+
9434
+ /**
9435
+ * 运行期再消毒一遍内联语料(不信任模块级常量的形状)。
9436
+ * 手改过 client.js、或旧版本内联了坏数据时,坏条目**逐条丢弃**,
9437
+ * 好条目照用——而不是整体抛错把问候功能打死。
9438
+ * @param pack - 内联的 BUNDLED_MOOD_LINES。
9439
+ * @returns 消毒后的行数组(可能为空;空库由 moodLineOf 报警告)。
9440
+ */
9441
+ function sanitizeMoodLines(pack) {
9442
+ const source = pack !== null && typeof pack === 'object' && Array.isArray(pack.lines) ? pack.lines : []
9443
+ const out = []
9444
+ for (const line of source) {
9445
+ if (line === null || typeof line !== 'object') continue
9446
+ if (typeof line.text !== 'string' || line.text.length === 0 || line.text.length > 200) continue
9447
+ if (!MOOD_CATEGORIES.includes(line.category)) continue
9448
+ if (typeof line.id !== 'string' || line.id === '') continue
9449
+ const clean = {
9450
+ id: line.id,
9451
+ seq: typeof line.seq === 'number' && Number.isFinite(line.seq) ? line.seq : 0,
9452
+ category: line.category,
9453
+ text: line.text,
9454
+ }
9455
+ if (line.category === 'holiday'
9456
+ && line.holiday !== null && typeof line.holiday === 'object'
9457
+ && typeof line.holiday.from === 'string' && typeof line.holiday.to === 'string') {
9458
+ clean.holiday = { from: line.holiday.from, to: line.holiday.to }
9459
+ }
9460
+ out.push(clean)
9461
+ }
9462
+ return out
9463
+ }
9464
+
9465
+ /** 消毒副本:mood 运行区只读这一份,永远不直接摸 BUNDLED_MOOD_LINES。 */
9466
+ const MOOD_LINES = sanitizeMoodLines(BUNDLED_MOOD_LINES)
9467
+
9468
+ /**
9469
+ * 读用户设置。localStorage 里的东西不可信(readPetState 同款理由):
9470
+ * 逐字段消毒,坏字段回缺省;**缺省是开**(用户定的"默认开")。
9471
+ * @returns `{ on, speed, direction, zone }`。
9472
+ */
9473
+ function readMoodState() {
9474
+ const fallback = { on: true, speed: 'medium', direction: 'ltr', zone: 'center' }
9475
+ try {
9476
+ const raw = window.localStorage?.getItem(MOOD_KEY)
9477
+ if (typeof raw !== 'string' || raw === '') return fallback
9478
+ const parsed = JSON.parse(raw)
9479
+ if (parsed === null || typeof parsed !== 'object') return fallback
9480
+ return {
9481
+ on: parsed.on === true,
9482
+ speed: typeof parsed.speed === 'string' && parsed.speed in MOOD_SPEEDS ? parsed.speed : fallback.speed,
9483
+ direction: parsed.direction === 'rtl' ? 'rtl' : fallback.direction,
9484
+ zone: typeof parsed.zone === 'string' && parsed.zone in MOOD_ZONE_GEOMETRY ? parsed.zone : fallback.zone,
9485
+ }
9486
+ } catch {
9487
+ return fallback
9488
+ }
9489
+ }
9490
+
9491
+ /**
9492
+ * 合并写回设置。写失败只记录 —— localStorage 被禁用时问候仍在本次会话里活着。
9493
+ * @param patch - 要合并的字段(先经调用方认可)。
9494
+ */
9495
+ function saveMoodState(patch) {
9496
+ try {
9497
+ window.localStorage?.setItem(MOOD_KEY, JSON.stringify({ ...readMoodState(), ...patch }))
9498
+ } catch (error) {
9499
+ console.error('[theme-gallery] could not save the mood state:', error)
9500
+ }
9501
+ }
9502
+
9503
+ /** 本地日期键 `YYYY-MM-DD`(选句的"今天"以本地时区为准)。 */
9504
+ function moodDateKey(date) {
9505
+ const pad = (n) => String(n).padStart(2, '0')
9506
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
9507
+ }
9508
+
9509
+ /** `MM-DD` 键(节日区间比较用)。 */
9510
+ function moodMonthDay(date) {
9511
+ const pad = (n) => String(n).padStart(2, '0')
9512
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
9513
+ }
9514
+
9515
+ /**
9516
+ * 当前小时属于哪个时段池。夜深的环形边界:小时 <5 时加 24 归入"昨晚延续"。
9517
+ * @param hour - `date.getHours()`(0–23)。
9518
+ * @returns 池名。
9519
+ */
9520
+ function moodPoolName(hour) {
9521
+ const h = hour < 5 ? hour + 24 : hour
9522
+ for (const range of MOOD_TIME_POOLS) {
9523
+ if (h >= range.from && h < range.to) return range.pool
9524
+ }
9525
+ return 'general'
9526
+ }
9527
+
9528
+ /**
9529
+ * 一条节日语料今天是否命中。`from > to` 视为跨年区间(如 12-31 → 01-02)。
9530
+ * @param line - 语料行(带 holiday 区间)。
9531
+ * @param mmdd - 今天的 `MM-DD`。
9532
+ * @returns 命中与否。
9533
+ */
9534
+ function moodHolidayMatches(line, mmdd) {
9535
+ if (line.holiday === undefined) return false
9536
+ const { from, to } = line.holiday
9537
+ if (from <= to) return mmdd >= from && mmdd <= to
9538
+ return mmdd >= from || mmdd <= to
9539
+ }
9540
+
9541
+ /**
9542
+ * 此刻该用的语料池:节日命中 → 节日池(全部命中条目的并集);否则时段池;
9543
+ * 池空(理论不发生——建期断言五类非空)回落 general。
9544
+ * @param date - "现在"。
9545
+ * @param lines - 消毒后的语料。
9546
+ * @returns `{ pool, items }`;items 可为空数组。
9547
+ */
9548
+ function moodPicksFor(date, lines) {
9549
+ const mmdd = moodMonthDay(date)
9550
+ const holiday = lines.filter((line) => line.category === 'holiday' && moodHolidayMatches(line, mmdd))
9551
+ if (holiday.length > 0) return { pool: 'holiday', items: holiday }
9552
+ const poolName = moodPoolName(date.getHours())
9553
+ const picked = lines.filter((line) => line.category === poolName)
9554
+ if (picked.length > 0) return { pool: poolName, items: picked }
9555
+ return { pool: 'general', items: lines.filter((line) => line.category === 'general') }
9556
+ }
9557
+
9558
+ /** FNV-1a 32 位——日期种子的原料(无依赖、确定性)。 */
9559
+ function moodHash(text) {
9560
+ let hash = 0x811c9dc5
9561
+ for (let at = 0; at < text.length; at += 1) {
9562
+ hash ^= text.charCodeAt(at)
9563
+ hash = Math.imul(hash, 0x01000193)
9564
+ }
9565
+ return hash >>> 0
9566
+ }
9567
+
9568
+ /** mulberry32 PRNG——小、快、确定性,够洗牌用。 */
9569
+ function mulberry32(seed) {
9570
+ let a = seed >>> 0
9571
+ // 先把函数赋给常量再返回:`return (…) =>` 里的 `return (` 会被 scope 审计
9572
+ // 读成"调用了名为 return 的函数"(wcagContrast 同一条教训的第三种形态)。
9573
+ const next = () => {
9574
+ a = (a + 0x6d2b79f5) | 0
9575
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
9576
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
9577
+ const out = ((t ^ (t >>> 14)) >>> 0) / 4294967296
9578
+ return out
9579
+ }
9580
+ return next
9581
+ }
9582
+
9583
+ /**
9584
+ * 当日确定性洗牌:同一天同池顺序稳定(会话重启不换血),跨天不同——
9585
+ * "每天不重样"的体验由种子里的日期实现,不依赖任何在线内容。
9586
+ * @param items - 池内语料。
9587
+ * @param date - "今天"。
9588
+ * @param pool - 池名(进种子,跨池顺序也不同)。
9589
+ * @returns 新数组(不改入参)。
9590
+ */
9591
+ function moodDayOrder(items, date, pool) {
9592
+ const next = mulberry32(moodHash(`${moodDateKey(date)}|${pool}`))
9593
+ const order = [...items]
9594
+ for (let at = order.length - 1; at > 0; at -= 1) {
9595
+ const swap = Math.floor(next() * (at + 1))
9596
+ const hold = order[at]
9597
+ order[at] = order[swap]
9598
+ order[swap] = hold
9599
+ }
9600
+ return order
9601
+ }
9602
+
9603
+ /** 读选句指针;坏/缺返回 null(轮换逻辑自愈到 index 0)。 */
9604
+ function moodPointer() {
9605
+ try {
9606
+ const raw = window.localStorage?.getItem(MOOD_POINTER_KEY)
9607
+ if (typeof raw !== 'string' || raw === '') return null
9608
+ const parsed = JSON.parse(raw)
9609
+ if (parsed === null || typeof parsed !== 'object') return null
9610
+ return {
9611
+ date: typeof parsed.date === 'string' ? parsed.date : '',
9612
+ pool: typeof parsed.pool === 'string' ? parsed.pool : '',
9613
+ index: Number.isInteger(parsed.index) && parsed.index >= 0 ? parsed.index : 0,
9614
+ lastId: typeof parsed.lastId === 'string' ? parsed.lastId : '',
9615
+ }
9616
+ } catch {
9617
+ return null
9618
+ }
9619
+ }
9620
+
9621
+ /** 写选句指针;写失败只是明天从头开始,不值得打断谁。 */
9622
+ function saveMoodPointer(pointer) {
9623
+ try {
9624
+ window.localStorage?.setItem(MOOD_POINTER_KEY, JSON.stringify(pointer))
9625
+ } catch { /* 存储被禁时问候仍可显示,只是换条顺序回到队首 */ }
9626
+ }
9627
+
9628
+ /**
9629
+ * 选下一条并推进指针(每次调用都消费一格——启动首条也一样,指针保证
9630
+ * 同一天内尽量不重复;池耗尽回绕,回绕后与上一条相同且池里不止一条时跳一格)。
9631
+ * @param now - "现在"。
9632
+ * @param lines - 消毒后的语料。
9633
+ * @returns 语料行,或 null(语料不可用)。
9634
+ */
9635
+ function moodNextLine(now, lines) {
9636
+ if (!Array.isArray(lines) || lines.length === 0) return null
9637
+ const { pool, items } = moodPicksFor(now, lines)
9638
+ if (items.length === 0) return null
9639
+ const dateKey = moodDateKey(now)
9640
+ const pointer = moodPointer()
9641
+ const fresh = pointer === null || pointer.date !== dateKey || pointer.pool !== pool
9642
+ const start = fresh ? 0 : pointer.index
9643
+ const order = moodDayOrder(items, now, pool)
9644
+ let pick = start % order.length
9645
+ if (order.length > 1 && order[pick].id === pointer?.lastId) pick = (pick + 1) % order.length
9646
+ saveMoodPointer({ date: dateKey, pool, index: pick + 1, lastId: order[pick].id })
9647
+ return order[pick]
9648
+ }
9649
+
9650
+ /**
9651
+ * 应用/编辑菜单的右缘(问候区左端)。菜单是 preload 注入的固定元素
9652
+ * (`[data-windows-menu]`,见 preload 的 installWindowsMenu),
9653
+ * 侧栏折叠时它会挪位——每次都实测,几何自动跟随,不做写死常量。
9654
+ * @returns 菜单右缘 + 间隙;读不到时给兜底常量。
9655
+ */
9656
+ /**
9657
+ * 问候区左端。**用户二轮返工(截图)**:左端要让开**整个左侧菜单栏列**——
9658
+ * 侧栏与工作区有明显分界线(卡通素材也渲染在侧栏内),问候从分界线右侧开始,
9659
+ * 不再只是避开"编辑"按钮。所以取两者较大值:
9660
+ *
9661
+ * · 侧栏列的实测右缘(`sidebarColumn()`,展开 ≈ 280,即分界线)+ 小呼吸位;
9662
+ * · 应用/编辑菜单的右缘(侧栏**收起**时列宽为 0、菜单挪到 84px 起,此时由它兜底)。
9663
+ *
9664
+ * 都读不到(异常环境/旧版本)才落常量兜底。每次都实测:侧栏宽度是用户可拖的,
9665
+ * 收起/展开也随时切——写死必错(SKILL §13 同一条哲学)。
9666
+ * @returns 左端 x(px)。
9667
+ */
9668
+ function moodMenuRight() {
9669
+ let boundary = 0
9670
+ try {
9671
+ const column = sidebarColumn()
9672
+ if (column !== null && typeof column.getBoundingClientRect === 'function') {
9673
+ const rect = column.getBoundingClientRect()
9674
+ if (Number.isFinite(rect.right) && rect.right > 0) boundary = rect.right + MOOD_SIDEBAR_GAP
9675
+ }
9676
+ } catch { /* 桩环境没有 rect —— 由菜单/兜底接管 */ }
9677
+ try {
9678
+ const host = document.querySelector('[data-windows-menu]')
9679
+ if (host !== null && typeof host.getBoundingClientRect === 'function') {
9680
+ const rect = host.getBoundingClientRect()
9681
+ if (Number.isFinite(rect.right) && rect.right > 0) boundary = Math.max(boundary, rect.right + MOOD_MENU_GAP)
9682
+ }
9683
+ } catch { /* 同上 */ }
9684
+ if (boundary > 0) return boundary
9685
+ return MOOD_FALLBACK_MENU_RIGHT
9686
+ }
9687
+
9688
+ /**
9689
+ * 位置区域的矩形:5 等份映射(MOOD_ZONE_GEOMETRY)作用在
9690
+ * 「问候区左端(侧栏分界线右侧)→ 窗口按钮左缘」这段上。
9691
+ * @param zone - `readMoodState().zone`。
9692
+ * @param viewportWidth - 窗口宽。
9693
+ * @param menuRight - `moodMenuRight()` 的读数(即左端)。
9694
+ * @returns `{ left, width }`(px),几何不可算时 null。
9695
+ */
9696
+ function moodZoneRect(zone, viewportWidth, menuRight) {
9697
+ const geo = MOOD_ZONE_GEOMETRY[zone]
9698
+ if (geo === undefined) return null
9699
+ const right = viewportWidth - MOOD_WINBTN_RESERVE
9700
+ const left = Math.min(menuRight, right - 160)
9701
+ // 左端为负 = 窗口窄到连菜单都放不下(异常环境)——宁可不渲染(自检行会给读数),
9702
+ // 也不要把条画到屏幕外去。
9703
+ if (!(right > left) || left < 0) return null
9704
+ const unit = (right - left) / 5
9705
+ const rectLeft = left + unit * (geo.first - 1)
9706
+ const rectWidth = unit * geo.parts
9707
+ if (!Number.isFinite(rectLeft) || !Number.isFinite(rectWidth) || rectWidth <= 0) return null
9708
+ return { left: rectLeft, width: rectWidth }
9709
+ }
9710
+
9711
+ /** 问候条的样式表(一次性安装;元素树全部经 createElement/textContent)。 */
9712
+ const MOOD_CSS = [
9713
+ `#${MOOD_BAR_ID}{position:fixed;top:0;height:var(--dsh-windows-titlebar-height,40px);`
9714
+ + 'display:flex;align-items:center;overflow:hidden;pointer-events:none;'
9715
+ + 'font-size:12px;letter-spacing:.12em;color:var(--dsw-alias-label-tertiary)}',
9716
+ '.dsh-mood-track{display:inline-flex;white-space:nowrap;will-change:transform;'
9717
+ + 'animation:dsh-mood-rtl linear infinite}',
9718
+ '[data-dsh-mood-dir="ltr"] .dsh-mood-track{animation-name:dsh-mood-ltr}',
9719
+ '@keyframes dsh-mood-rtl{from{transform:translateX(0)}to{transform:translateX(-50%)}}',
9720
+ '@keyframes dsh-mood-ltr{from{transform:translateX(-50%)}to{transform:translateX(0)}}',
9721
+ '.dsh-mood-half{display:inline-flex;align-items:center}',
9722
+ /* 句尾间隙 = **整个区域宽**(内联 width 由 moodBuildBar 按实测写入):
9723
+ 间隙 ≥ 区域宽时,数学上保证任一时刻区域内至多一句可见 —— 用户返工
9724
+ (2026-09-27 截图):"同一时刻,只有一句话显示在一处"。 */
9725
+ '.dsh-mood-text{flex:none}',
9726
+ '.dsh-mood-gap{flex:none;display:inline-block}',
9727
+ /* reduced-motion:功能保留、只停动画(挂件先例)——静止显示一份,超长省略。
9728
+ ⚠ 全杀规则必须待在媒体查询**里面**:裸写一条无条件 `animation:none!important`
9729
+ 会把跑马灯动画本身一起杀掉(实机读数抓到过:animationName 变成 none)。 */
9730
+ '@media (prefers-reduced-motion:reduce){'
9731
+ + `#${MOOD_BAR_ID} *{animation:none!important}`
9732
+ + '.dsh-mood-half+.dsh-mood-half{display:none}'
9733
+ + `#${MOOD_BAR_ID} .dsh-mood-half{overflow:hidden;text-overflow:ellipsis;max-width:100%}}`,
9734
+ ].join('\n')
9735
+
9736
+ /** 样式表是否已在文档里(自检读数之一)。 */
9737
+ function moodCssInstalled() {
9738
+ if (typeof document === 'undefined' || typeof document.querySelector !== 'function') return false
9739
+ return document.querySelector('style[data-plugin-css="theme-gallery/mood"]') !== null
9740
+ }
9741
+
9742
+ /** 安装样式表(幂等)。 */
9743
+ function installMoodCss() {
9744
+ if (typeof document === 'undefined' || typeof document.querySelector !== 'function') return
9745
+ if (moodCssInstalled()) return
9746
+ const tag = document.createElement('style')
9747
+ tag.dataset.plugin = 'dsh-theme-gallery'
9748
+ tag.dataset.pluginCss = 'theme-gallery/mood'
9749
+ tag.textContent = MOOD_CSS
9750
+ document.head.appendChild(tag)
9751
+ }
9752
+
9753
+ /** 拆除样式表(teardown 路径用)。 */
9754
+ function removeMoodCss() {
9755
+ if (typeof document === 'undefined' || typeof document.querySelector !== 'function') return
9756
+ for (const stale of document.querySelectorAll('style[data-plugin-css="theme-gallery/mood"]')) stale.remove()
9757
+ }
9758
+
9759
+ /** 问候条运行态。`enabled` 是 localStorage 的内存镜像(petRun 同款理由)。 */
9760
+ const moodRun = { enabled: false, timer: null, retryTimer: null, retryTries: 0, resizeBound: false, fingerprint: '', line: null, pool: '-' }
9761
+
9762
+ /**
9763
+ * 座位缺席的有界重试。shell.overlay 的渲染时机由外壳决定,常常晚于本插件的
9764
+ * 首轮 publish(实机读数:指针已走、座位已在、条还没建 —— 就是这个缺口),
9765
+ * 而之后可能再没有 publish 路过。所以缺席时用**单个可取消句柄**每 2 秒重试,
9766
+ * 至多 `MOOD_SEAT_RETRY_MAX` 次(约 30 秒)—— 建成即停,拆条即清,
9767
+ * 不做持续轮询(有界工作的家规)。
9768
+ */
9769
+ const MOOD_SEAT_RETRY_MS = 2000
9770
+ const MOOD_SEAT_RETRY_MAX = 15
9771
+
9772
+ function moodArmSeatRetry() {
9773
+ if (moodRun.retryTimer !== null || moodRun.retryTries >= MOOD_SEAT_RETRY_MAX) return
9774
+ if (typeof window === 'undefined' || typeof window.setTimeout !== 'function') return
9775
+ moodRun.retryTimer = window.setTimeout(() => {
9776
+ moodRun.retryTimer = null
9777
+ moodRun.retryTries += 1
9778
+ try {
9779
+ if (!moodRun.enabled) return
9780
+ const seat = document.querySelector(MOOD_SEAT_SELECTOR)
9781
+ if (seat === null) {
9782
+ moodArmSeatRetry()
9783
+ return
9784
+ }
9785
+ moodRun.retryTries = 0
9786
+ moodBuildBar()
9787
+ } catch (error) {
9788
+ console.error('[theme-gallery] could not retry the mood seat:', error)
9789
+ }
9790
+ }, MOOD_SEAT_RETRY_MS)
9791
+ }
9792
+
9793
+ function moodCancelSeatRetry() {
9794
+ moodClearTimer(moodRun.retryTimer)
9795
+ moodRun.retryTimer = null
9796
+ }
9797
+
9798
+ /** 自检读数。挂件 petReport 同款:每条退出路径都写,失败形态是"什么都不出现",没有读数就查不下去。 */
9799
+ let moodReport
9800
+ function noteMoodReport(patch) {
9801
+ moodReport = { ...(moodReport ?? {}), ...patch }
9802
+ }
9803
+
9804
+ /** 例行读数用的中文标签(自检行是给人读的)。 */
9805
+ const MOOD_ZONE_LABELS = { left: '左靠齐', center: '居中', indent: '左右缩进', right: '右靠齐' }
9806
+ const MOOD_SPEED_LABELS = { slow: '慢速', medium: '中速', fast: '快速' }
9807
+
9808
+ /**
9809
+ * 面板自检行(petLineOf 同款形状):关着返回 null;开着出问题给 ⚠ 警告;
9810
+ * 一切正常给一行读数。警告无条件显示、读数走 debug 开关——既有口径。
9811
+ * @param on - 问候是否开启(store 的 moodOn)。
9812
+ * @returns 自检行或 null。
9813
+ */
9814
+ function moodLineOf(on) {
9815
+ try {
9816
+ if (on !== true) return null
9817
+ const r = moodReport
9818
+ if (r === undefined || r.enabled !== true) return '⚠ 问候已开启但尚未同步(syncMoodBar 未运行)'
9819
+ if (r.seat !== 'ok') return `⚠ 问候条未建出:${r.note ?? '座位(shell.overlay)尚未渲染'}`
9820
+ if (r.css !== true) return '⚠ 问候样式表未生效(MOOD_CSS 不在文档中)'
9821
+ if (r.line === null || r.line === undefined) return `⚠ 语料不可用:${r.note ?? '没有可用语料'}`
9822
+ return `问候自检通过 · 条=${r.line} 池=${r.pool} 区域=${r.zoneLabel} 方向=${r.dirLabel} 速度=${r.speedLabel} 左=${r.left} 右=${r.right}`
9823
+ } catch (error) {
9824
+ return `⚠ 问候自检失败: ${String(error && error.message ? error.message : error)}`
9825
+ }
9826
+ }
9827
+
9828
+ /** 与 petLineIsWarning 同一条判据:⚠ 开头或带"失败"的行不算例行汇报。 */
9829
+ function moodLineIsWarning(line) {
9830
+ return typeof line === 'string' && (line.startsWith('⚠') || line.includes('失败'))
9831
+ }
9832
+
9833
+ /** 拆掉问候条元素(不动计时器——那是 teardown 的事)。 */
9834
+ function removeMoodBar() {
9835
+ if (typeof document === 'undefined' || typeof document.getElementById !== 'function') return
9836
+ const bar = document.getElementById(MOOD_BAR_ID)
9837
+ if (bar !== null) bar.remove()
9838
+ }
9839
+
9840
+ /**
9841
+ * 按当前状态(重)建问候条。幂等判据在最前:指纹没变一个字节都不写
9842
+ * (bounded-work 的守卫规矩;publish 每次都路过这里,不能每次都重建 DOM)。
9843
+ *
9844
+ * 文字**只经 textContent** 进 DOM;两半内容逐字节相同,translateX 0↔-50% 的
9845
+ * 循环天然无缝;时长 = 半宽 ÷ 速度档,速度换算全部落在动画时长上。
9846
+ */
9847
+ function moodBuildBar() {
9848
+ if (typeof document === 'undefined' || typeof document.getElementById !== 'function') return
9849
+ const state = readMoodState()
9850
+ if (!moodRun.enabled || !ambientEnabled()) {
9851
+ removeMoodBar()
9852
+ noteMoodReport({
9853
+ enabled: moodRun.enabled, seat: 'absent', css: moodCssInstalled(), line: null,
9854
+ note: moodRun.enabled ? '急停开关(ambient:false)' : '开关为关',
9855
+ })
9856
+ return
9857
+ }
9858
+ installMoodCss()
9859
+ const seatNode = document.querySelector(MOOD_SEAT_SELECTOR)
9860
+ if (seatNode === null) {
9861
+ noteMoodReport({
9862
+ enabled: true, seat: 'absent', css: moodCssInstalled(),
9863
+ line: moodRun.line === null ? null : moodRun.line.id,
9864
+ pool: moodRun.pool, note: '座位(shell.overlay)尚未渲染',
9865
+ })
9866
+ moodArmSeatRetry()
9867
+ return
9868
+ }
9869
+ const viewport = petViewport()
9870
+ const menuRight = moodMenuRight()
9871
+ const rect = moodZoneRect(state.zone, viewport.width, menuRight)
9872
+ if (rect === null) {
9873
+ noteMoodReport({
9874
+ enabled: true, seat: 'ok', css: moodCssInstalled(),
9875
+ line: moodRun.line === null ? null : moodRun.line.id, pool: moodRun.pool,
9876
+ note: `几何不可算(viewport=${viewport.width} 菜单右缘=${menuRight})`,
9877
+ })
9878
+ return
9879
+ }
9880
+ const active = moodRun.line
9881
+ if (active === null) {
9882
+ noteMoodReport({ enabled: true, seat: 'ok', css: moodCssInstalled(), line: null, pool: moodRun.pool, note: '语料不可用' })
9883
+ return
9884
+ }
9885
+ const fingerprint = [active.id, state.speed, state.direction, state.zone, rect.left, rect.width, viewport.width].join('|')
9886
+ let bar = document.getElementById(MOOD_BAR_ID)
9887
+ if (bar !== null && moodRun.fingerprint === fingerprint) {
9888
+ moodCancelSeatRetry()
9889
+ moodRun.retryTries = 0
9890
+ noteMoodReport({
9891
+ enabled: true, seat: 'ok', css: true, line: active.id, pool: moodRun.pool,
9892
+ zoneLabel: MOOD_ZONE_LABELS[state.zone], dirLabel: state.direction === 'ltr' ? '左→右' : '右→左',
9893
+ speedLabel: MOOD_SPEED_LABELS[state.speed], left: Math.round(rect.left), right: Math.round(rect.left + rect.width),
9894
+ })
9895
+ return
9896
+ }
9897
+ // —— 指纹变了:整条重建。重建比 diff 便宜,也是把旧速度/旧方向的动画一并带走的唯一途径。
9898
+ if (bar === null) {
9899
+ bar = document.createElement('div')
9900
+ bar.id = MOOD_BAR_ID
9901
+ }
9902
+ bar.textContent = ''
9903
+ bar.dataset.dshMoodDir = state.direction
9904
+ bar.style.left = `${rect.left}px`
9905
+ bar.style.width = `${rect.width}px`
9906
+ // **先把 bar 挂进座位再量宽度**:脱离 DOM 的元素 getBoundingClientRect() 是 0
9907
+ // (实机读数抓到过)。track 也一样——量宽前先把它挂进 bar。
9908
+ seatNode.appendChild(bar)
9909
+ const track = document.createElement('div')
9910
+ track.className = 'dsh-mood-track'
9911
+ bar.appendChild(track)
9912
+ // 每半 = **一句 + 一段等于区域宽的间隙**。间隙 ≥ 区域宽 ⇒ 两句不可能同时
9913
+ // 落在区域内(B 的左缘与 A 的右缘至少隔一个区域宽),滚动到接缝处 A 出、
9914
+ // B 进,视觉上"同一时刻只有一句话显示在一处"(用户返工要求)。
9915
+ const buildHalf = () => {
9916
+ const half = document.createElement('span')
9917
+ half.className = 'dsh-mood-half'
9918
+ const text = document.createElement('span')
9919
+ text.className = 'dsh-mood-text'
9920
+ text.textContent = active.text
9921
+ half.appendChild(text)
9922
+ const gap = document.createElement('span')
9923
+ gap.className = 'dsh-mood-gap'
9924
+ gap.style.width = `${rect.width}px`
9925
+ half.appendChild(gap)
9926
+ track.appendChild(half)
9927
+ return half
9928
+ }
9929
+ const first = buildHalf()
9930
+ track.appendChild(first.cloneNode(true))
9931
+ // 建成即停:座位重试链清零(它只在缺席时武装)。
9932
+ moodCancelSeatRetry()
9933
+ moodRun.retryTries = 0
9934
+ // 区域随窗口宽重算:唯一的事件 listener,首次建条时绑定、teardown 时拆除。
9935
+ if (!moodRun.resizeBound && typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
9936
+ window.addEventListener('resize', moodOnResize)
9937
+ moodRun.resizeBound = true
9938
+ }
9939
+ const halfWidth = first.getBoundingClientRect().width
9940
+ const speed = MOOD_SPEEDS[state.speed] ?? MOOD_SPEEDS.medium
9941
+ const duration = halfWidth > 0 && Number.isFinite(halfWidth) ? halfWidth / speed : 0
9942
+ // 时长非法(量不到宽度)就不写样式——交给 reduced-motion 的静止形态兜底,绝不写 NaN。
9943
+ if (duration > 0 && Number.isFinite(duration)) track.style.animationDuration = `${duration}s`
9944
+ // 字体加载完成后**重建一次**:副本数与滚动时长刚才按回退字体量的(实机读数:
9945
+ // 12 份、47.8s),字体就位后宽度才真实。一次性 promise、指纹守卫仍在 —— 有界。
9946
+ if (typeof document !== 'undefined' && document.fonts?.ready?.then === 'function') {
9947
+ document.fonts.ready.then(() => {
9948
+ if (moodRun.fingerprint !== fingerprint || moodRun.enabled !== true) return
9949
+ moodRun.fingerprint = ''
9950
+ moodBuildBar()
9951
+ }).catch(() => { /* 字体 API 异常就用首次的度量,不追 */ })
9952
+ }
9953
+ moodRun.fingerprint = fingerprint
9954
+ noteMoodReport({
9955
+ enabled: true, seat: 'ok', css: true, line: active.id, pool: moodRun.pool,
9956
+ zoneLabel: MOOD_ZONE_LABELS[state.zone], dirLabel: state.direction === 'ltr' ? '左→右' : '右→左',
9957
+ speedLabel: MOOD_SPEED_LABELS[state.speed], left: Math.round(rect.left), right: Math.round(rect.left + rect.width),
9958
+ })
9959
+ }
9960
+
9961
+ /**
9962
+ * 计时器只走 **`window.setTimeout` / `window.clearTimeout`**(挂件同款):
9963
+ * 测试桩拦得住,也绝不留一个裸的全局定时器把进程吊住(有界工作的家规)。
9964
+ */
9965
+ function moodClearTimer(handle) {
9966
+ if (handle === null) return
9967
+ if (typeof window !== 'undefined' && typeof window.clearTimeout === 'function') window.clearTimeout(handle)
9968
+ }
9969
+
9970
+ /** 35 分钟后的下一次换条(单句柄链式;任何路径先 clear 自己)。 */
9971
+ function moodScheduleNext() {
9972
+ moodClearTimer(moodRun.timer)
9973
+ moodRun.timer = null
9974
+ if (typeof window === 'undefined' || typeof window.setTimeout !== 'function') return
9975
+ moodRun.timer = window.setTimeout(() => {
9976
+ moodRun.timer = null
9977
+ try {
9978
+ if (!moodRun.enabled) return
9979
+ const line = moodNextLine(new Date(), MOOD_LINES)
9980
+ if (line === null) {
9981
+ noteMoodReport({ enabled: true, seat: moodReport?.seat ?? 'absent', css: moodCssInstalled(), line: null, pool: moodRun.pool, note: '语料不可用' })
9982
+ return
9983
+ }
9984
+ moodRun.line = line
9985
+ moodRun.pool = line.category === 'holiday' ? 'holiday' : moodRun.pool
9986
+ moodBuildBar()
9987
+ } catch (error) {
9988
+ console.error('[theme-gallery] could not rotate the mood line:', error)
9989
+ } finally {
9990
+ moodScheduleNext()
9991
+ }
9992
+ }, MOOD_PERIOD_MS)
9993
+ }
9994
+
9995
+ /**
9996
+ * 问候条的同步入口(幂等):publish 顺路调用、开关与设置路径显式调用。
9997
+ * 与 syncPetStage 同一条哲学——每条退出路径都写 moodReport。
9998
+ * 计时器只在第一次启动(句柄存在就不动它),换条节奏不随 publish 漂移。
9999
+ */
10000
+ function syncMoodBar() {
10001
+ try {
10002
+ const state = readMoodState()
10003
+ moodRun.enabled = state.on
10004
+ if (!state.on) {
10005
+ moodTeardown()
10006
+ return
10007
+ }
10008
+ if (moodRun.timer === null) moodScheduleNext()
10009
+ if (moodRun.line === null) {
10010
+ const line = moodNextLine(new Date(), MOOD_LINES)
10011
+ moodRun.line = line
10012
+ if (line !== null) moodRun.pool = line.category
10013
+ }
10014
+ moodBuildBar()
10015
+ } catch (error) {
10016
+ console.error('[theme-gallery] could not sync the mood bar:', error)
10017
+ }
10018
+ }
10019
+
10020
+ /** resize 时区域要重算(唯一的事件 listener;挂载/拆卸成对)。 */
10021
+ function moodOnResize() {
10022
+ try {
10023
+ moodBuildBar()
10024
+ } catch (error) {
10025
+ console.error('[theme-gallery] could not resize the mood bar:', error)
10026
+ }
10027
+ }
10028
+
10029
+ /**
10030
+ * 全部拆除:计时器、resize listener、条元素、样式表、运行态。
10031
+ * **刻意不叫 `…Dispose`**——那是自发光守卫审计里主题层专用模式的名字(规则 1)。
10032
+ */
10033
+ function moodTeardown() {
10034
+ moodClearTimer(moodRun.timer)
10035
+ moodRun.timer = null
10036
+ moodCancelSeatRetry()
10037
+ moodRun.retryTries = 0
10038
+ if (moodRun.resizeBound && typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
10039
+ window.removeEventListener('resize', moodOnResize)
10040
+ }
10041
+ moodRun.resizeBound = false
10042
+ removeMoodBar()
10043
+ removeMoodCss()
10044
+ moodRun.fingerprint = ''
10045
+ moodRun.line = null
10046
+ noteMoodReport({ enabled: false, seat: 'absent', css: false, line: null, pool: '-', note: '开关为关' })
10047
+ }
10048
+
10049
+ /* ═══════════════════ 菜单折叠(侧栏入口收放 + 设置页常驻面板)═══════════════════
10050
+ *
10051
+ * 方案二(用户定):设置页入口**常驻**,侧栏入口由本开关收放。三件事:
10052
+ * ① 面板第 15 张卡「菜单折叠」(默认关)—— 卡面带路径提示,点击只改 localStorage;
10053
+ * ② `settings.section` 槽位注册一个**常驻**的一级设置分节(用户指定:agent预设下方),
10054
+ * 内容 = 完整面板(ThemeGalleryPage + 同一个 pinned store),侧栏收起后面板仍可达
10055
+ * —— 这是"不会把自己锁在外面"的关键;
10056
+ * ③ 侧栏入口的注册/撤销随开关走(槽位 disposer,即时生效,不重启、不碰 CSS)。
10057
+ * 不写主题服务:折叠状态只存 localStorage(铁律)。
10058
+ */
10059
+
10060
+ /** 折叠卡的 id、标题与 tooltip 文案。 */
10061
+ const MENU_COLLAPSE_WIDGET = {
10062
+ id: 'menu-collapse',
10063
+ label: '菜单折叠',
10064
+ description: '开启后,左侧栏不再显示「主题皮肤」入口;设置 → 通用设置(agent预设 下方)'
10065
+ + '里有轻量入口,可一键打开面板或恢复侧栏显示。关闭后恢复侧栏显示。默认关闭。',
10066
+ }
10067
+
10068
+ /** 折叠状态记在这里。与皮肤 id 同一条铁律:绝不写 `ui-theme.preference`。 */
10069
+ const MENU_COLLAPSE_KEY = 'theme-gallery:menu-collapse'
10070
+
10071
+ /**
10072
+ * 设置分节在设置页导航里的位置(order)。
10073
+ *
10074
+ * 探针读数(app.asar + profile 插件,2026-09-27):settings.section 里 cool-theme=5、
10075
+ * skin-center=120、pet=130;General 行 appearance=10、font-size=11。「agent预设」的
10076
+ * 注册点不在客户端 bundle 里(无法静态标定它的 order),先取 **135**(排在已知插件
10077
+ * 分节之后);实机若不在 agent预设 下方,改这一个数字即可。
10078
+ */
10079
+ const SETTINGS_SECTION_ORDER = 135
10080
+
10081
+ /**
10082
+ * 读取并消毒折叠状态。缺省**关**(= 侧栏显示现状),坏数据回缺省。
10083
+ * @returns `{ collapsed }`。
10084
+ */
10085
+ function readMenuCollapseState() {
10086
+ try {
10087
+ const raw = window.localStorage?.getItem(MENU_COLLAPSE_KEY)
10088
+ if (typeof raw !== 'string' || raw === '') return { collapsed: false }
10089
+ const parsed = JSON.parse(raw)
10090
+ if (parsed === null || typeof parsed !== 'object') return { collapsed: false }
10091
+ return { collapsed: parsed.collapsed === true }
10092
+ } catch {
10093
+ return { collapsed: false }
10094
+ }
10095
+ }
10096
+
10097
+ /** 合并写回折叠状态(写失败只记录 —— localStorage 被禁用时本次会话仍可玩)。 */
10098
+ function saveMenuCollapseState(patch) {
10099
+ try {
10100
+ window.localStorage?.setItem(MENU_COLLAPSE_KEY, JSON.stringify({ ...readMenuCollapseState(), ...patch }))
10101
+ } catch (error) {
10102
+ console.error('[theme-gallery] could not save the menu-collapse state:', error)
10103
+ }
10104
+ }
10105
+
10106
+ /** 折叠开关现在是否开着(= 侧栏入口已收起)。 */
10107
+ function menuCollapsed() {
10108
+ return readMenuCollapseState().collapsed
10109
+ }
10110
+
10111
+ /** store 未就绪时分节读的兜底快照(hooks 必须恒在场,见 MenuSettingsSection 文档)。 */
10112
+ const SECTION_FALLBACK_SNAPSHOT = { labels: {}, selected: '', menuCollapsed: false }
10113
+
10114
+ /**
10115
+ * 设置页的常驻分节(方案二,形态 A —— 用户 2026-09-27 定):**轻量入口**。
10116
+ *
10117
+ * 实机读数:完整面板塞进弹窗内容列会白屏(根因 = `store.getState` 与真实
10118
+ * StoreInstance 契约漂移,真实只有 `getSnapshot` —— 已修),而且就算修好,
10119
+ * 弹窗也挡住大半屏幕,换肤的全屏效果在里面看不见。官方契约把 settings.section
10120
+ * 定位为"一个列表项一个设置页"(owner props 只有 close),dsh-pet 的分节同样
10121
+ * 是轻量表单 —— 所以分节只做三件事:报状态(当前皮肤 / 侧栏入口)、给
10122
+ * 「打开主题皮肤面板」跳转(`ctx.layout.selectPanel` —— 契约明说它是给插件
10123
+ * 做面板切换的横切面 + `close` 关弹窗)、折叠开着时给「恢复侧栏入口」。
10124
+ * 真正的换肤 / 挂件 / 问候操作都在主区全屏面板里完成。
10125
+ * @param props - 分节 props(t/close + inject 面成员)。
10126
+ * @returns 分节元素树。
10127
+ */
10128
+ function MenuSettingsSection(props) {
10129
+ const { t, close, store, toggleMenuCollapse } = props
10130
+ // hooks 规则:订阅恒在场 —— store 缺失(inject 面没解析)时订阅空源、读兜底
10131
+ // 快照,分节退化为引导文案,绝不白屏(白屏教训就在眼前)。
10132
+ // 两个包装都是刻意的:抽出方法会丢 this,直接给属性引用在真实引擎上不安全。
10133
+ const snapshot = useSyncExternalStore(
10134
+ (onStoreChange) => (store?.subscribe ? store.subscribe(onStoreChange) : () => {}),
10135
+ () => (store?.getSnapshot ? store.getSnapshot() : SECTION_FALLBACK_SNAPSHOT),
10136
+ )
10137
+ const skinLabel = snapshot?.labels?.[snapshot?.selected] ?? snapshot?.selected ?? ''
10138
+ const collapsed = snapshot?.menuCollapsed === true
10139
+ // 排版(用户 2026-09-27 返工):标题 → 说明 → 读数块(两行,label 左值右)→
10140
+ // 按钮组(主按钮跳转、次按钮恢复)—— 主次分明、垂直节奏,适配弹窗窄容器。
10141
+ return jsxs('div', {
10142
+ className: 'tg-section',
10143
+ children: [
10144
+ jsx('div', { className: 'tg-section-title', children: t('title') }),
10145
+ jsx('div', { className: 'tg-section-hint', children: t('sectionHint') }),
10146
+ jsxs('div', {
10147
+ className: 'tg-section-facts',
10148
+ children: [
10149
+ jsxs('div', {
10150
+ className: 'tg-section-row',
10151
+ children: [
10152
+ jsx('span', { children: t('sectionSkinLabel') }),
10153
+ jsx('span', { className: 'tg-section-value', children: skinLabel }),
10154
+ ],
10155
+ }),
10156
+ jsxs('div', {
10157
+ className: 'tg-section-row',
10158
+ children: [
10159
+ jsx('span', { children: t('sectionSidebarLabel') }),
10160
+ jsx('span', {
10161
+ className: 'tg-section-value',
10162
+ children: collapsed ? t('menuCollapsed') : t('menuShown'),
10163
+ }),
10164
+ ],
10165
+ }),
10166
+ ],
10167
+ }),
10168
+ jsxs('div', {
10169
+ className: 'tg-section-actions',
10170
+ children: [
10171
+ jsx('button', {
10172
+ type: 'button',
10173
+ className: 'tg-section-open',
10174
+ onClick: () => {
10175
+ // 软跳转:布局面不在 / 面板未注册都只留痕,绝不把设置弹窗带崩。
10176
+ try {
10177
+ if (ctx !== undefined && ctx !== null && typeof ctx.layout?.selectPanel === 'function') {
10178
+ ctx.layout.selectPanel(PANEL_ID)
10179
+ } else {
10180
+ console.error('[theme-gallery] ctx.layout.selectPanel is unavailable; cannot jump to the panel')
10181
+ }
10182
+ } catch (error) {
10183
+ console.error('[theme-gallery] could not jump to the panel:', error)
10184
+ }
10185
+ // 官方明说 close 是给"从分节离开设置"用的 —— 跳转后关掉弹窗,全屏效果可见。
10186
+ if (typeof close === 'function') close()
10187
+ },
10188
+ children: t('openPanel'),
10189
+ }),
10190
+ collapsed ? jsx('button', {
10191
+ type: 'button',
10192
+ className: 'tg-section-restore',
10193
+ onClick: () => {
10194
+ // 复用折叠卡的同一动作:写回 localStorage + 重新注册侧栏入口 + publish
10195
+ // 报回 store —— 分节经订阅重渲染,这颗按钮自己消失。
10196
+ try { toggleMenuCollapse() } catch (error) {
10197
+ console.error('[theme-gallery] could not restore the sidebar entry:', error)
10198
+ }
10199
+ },
10200
+ children: t('restoreSidebar'),
10201
+ }) : null,
10202
+ ],
10203
+ }),
10204
+ ],
10205
+ })
10206
+ }
10207
+
10208
+ /** 侧栏入口的 inject 句柄;undefined = 当前没有注册(已折叠)。 */
10209
+ let sidebarEntryDispose
10210
+
10211
+ /**
10212
+ * 侧栏入口的注册/撤销随折叠开关走(幂等)。
10213
+ *
10214
+ * 撤销用的是槽位系统自己的 disposer —— 官方机制,即时生效;**绝不**用 CSS 硬藏
10215
+ * (框架类名带哈希,§13 的教训)。每次状态变化都留痕(noteAmbientEvent),
10216
+ * "收起了/恢复了"必须可查,而不是靠用户盯着侧栏猜。
10217
+ */
10218
+ function syncSidebarEntry() {
10219
+ if (typeof ctx === 'undefined' || ctx === null) return
10220
+ if (menuCollapsed()) {
10221
+ if (sidebarEntryDispose !== undefined) {
10222
+ sidebarEntryDispose()
10223
+ sidebarEntryDispose = undefined
10224
+ noteAmbientEvent('侧栏入口已收起(菜单折叠开)')
10225
+ }
10226
+ return
10227
+ }
10228
+ if (sidebarEntryDispose !== undefined) return
10229
+ sidebarEntryDispose = ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
10230
+ name: 'sidebar.panellist',
10231
+ id: PANEL_ID,
10232
+ order: 30,
10233
+ locale: NS,
10234
+ label: () => zh.title,
10235
+ }, PanelGlyph))
10236
+ noteAmbientEvent('侧栏入口已显示(菜单折叠关)')
10237
+ }
10238
+
10239
+ /**
10240
+ * Render the main-column gallery page.
10241
+ *
10242
+ * `usePanelInfo` comes from the layout's GlobalStandardProps, so the page can
10243
+ * render nothing when another panel is selected without the shell having to
10244
+ * mount and unmount it.
10245
+ * @param props - composed slot props.
10246
+ * @returns the page element tree.
10247
+ */
10248
+ function ThemeGalleryPage({ t, setTheme, pickScheme, togglePet, pickPet, toggleMood, setMood, toggleMenuCollapse, useStore, usePanelInfo }) {
10249
+ const info = usePanelInfo((s) => s.activePanelId)
10250
+ const ids = useStore((s) => s.ids)
10251
+ const labels = useStore((s) => s.labels)
10252
+ const descriptions = useStore((s) => s.descriptions)
10253
+ const swatches = useStore((s) => s.swatches)
10254
+ const cardRows = useStore((s) => s.cardRows)
10255
+ const scheme = useStore((s) => s.scheme)
10256
+ const petEnabled = useStore((s) => s.petEnabled)
10257
+ const petKind = useStore((s) => s.petKind)
10258
+ const moodOn = useStore((s) => s.moodOn)
10259
+ const moodSpeed = useStore((s) => s.moodSpeed)
10260
+ const moodDirection = useStore((s) => s.moodDirection)
10261
+ const moodZone = useStore((s) => s.moodZone)
10262
+ const menuCollapsed = useStore((s) => s.menuCollapsed)
10263
+ const selected = useStore((s) => s.selected)
10264
+ const status = useStore((s) => s.status)
10265
+ // The layout keeps this page registered whatever the selection is, so it
6666
10266
  // renders nothing while another panel owns the main column.
6667
10267
  if (info !== PANEL_ID) return null
6668
10268
  // An empty picker must explain itself: the boot screen says only "failed",
@@ -6679,13 +10279,23 @@ window.__ModuleLoader__.load({
6679
10279
  // Detail lines are opt-in; a line that reports a problem is not detail.
6680
10280
  const scenery = sceneryLine(selected)
6681
10281
  const showScenery = scenery !== null && (debugEnabled() || sceneryLineIsWarning(scenery))
10282
+ // 挂件行同理:开了挂件却建不出舞台,警告必须无条件出现在面板上。
10283
+ const petLine = petLineOf(petEnabled === true)
10284
+ const showPet = petLine !== null && (debugEnabled() || petLineIsWarning(petLine))
10285
+ // 问候行同理:开着却没建出条(或样式缺失),必须无条件可见。
10286
+ const moodLine = moodLineOf(moodOn === true)
10287
+ const showMood = moodLine !== null && (debugEnabled() || moodLineIsWarning(moodLine))
6682
10288
  // ── THE HEADER COUNT IS SKINS, NOT CARDS ─────────────────────────────────
6683
10289
  //
6684
10290
  // The built-in 浅色 / 深色 cards are not skins — they only switch the palette back to the
6685
10291
  // official appearance — so counting them here would advertise a number that disagrees
6686
10292
  // with the README and with npm's description ("6 skins"). The cards are named in the
6687
10293
  // same line instead, so nothing is hidden by leaving them out of the number.
6688
- const builtInCards = ids.filter((id) => BUILT_IN_LABELS[id] !== undefined).length
10294
+ // 宠物挂件卡/心情问候卡/菜单折叠卡同理:它们不是主题、不换配色,只开关各自的
10295
+ // 功能,所以与内置卡一起排除在"皮肤数"之外(copy-consistency 的正则钉住的是
10296
+ // skinCount 那一行的形状)。
10297
+ const builtInCards = ids.filter((id) => BUILT_IN_LABELS[id] !== undefined
10298
+ || id === PET_WIDGET.id || id === MOOD_WIDGET.id || id === MENU_COLLAPSE_WIDGET.id).length
6689
10299
  const skinCount = ids.length - builtInCards
6690
10300
  return jsxs('div', {
6691
10301
  className: 'tg-page',
@@ -6724,6 +10334,21 @@ window.__ModuleLoader__.load({
6724
10334
  // shell exactly like clicking the card would — so the anchor becomes active
6725
10335
  // and the scheme layer has something to sit on.
6726
10336
  onPickScheme: (schemeId) => { pickScheme(schemeId) },
10337
+ // 挂件卡:点击 = 开关挂件(见 ThemeCard 的挂件分支)。开关状态也一并传下去,
10338
+ // 徽标与 aria-pressed 都读它。
10339
+ onTogglePet: () => { togglePet() },
10340
+ petEnabled: petEnabled === true,
10341
+ // 头像选择行:点亮哪颗、卡面画哪只,都读这个字段(publish 从 localStorage 报来)。
10342
+ petKind: typeof petKind === 'string' && petKind !== '' ? petKind : 'ban-ban',
10343
+ onPickPet: (kindId) => { pickPet(kindId) },
10344
+ // 心情问候卡:胶囊开关与三组 chips 都读这些字段(publish 从 localStorage 报来)。
10345
+ mood: { on: moodOn === true, speed: moodSpeed, direction: moodDirection, zone: moodZone },
10346
+ onToggleMood: () => { toggleMood() },
10347
+ onSetMood: (patch) => { setMood(patch) },
10348
+ // 菜单折叠卡:点击 = 收起/恢复侧栏入口(见 ThemeCard 的折叠分支)。
10349
+ // 状态从 localStorage 报来,徽标与 aria-pressed 都读它。
10350
+ menuCollapsed: menuCollapsed === true,
10351
+ onToggleMenuCollapse: () => { toggleMenuCollapse() },
6727
10352
  t,
6728
10353
  }, id)),
6729
10354
  }),
@@ -6734,7 +10359,7 @@ window.__ModuleLoader__.load({
6734
10359
  // reference block — and the caption is what keeps them from being mistaken for a
6735
10360
  // malfunction, which is exactly how a user read them. They stay CONDITIONAL:
6736
10361
  // detail waits for the debug switch, while a warning or a failure always prints.
6737
- (debugEnabled() || showScenery)
10362
+ (debugEnabled() || showScenery || showPet || showMood)
6738
10363
  ? jsxs('div', {
6739
10364
  className: 'tg-diag',
6740
10365
  children: [
@@ -6749,6 +10374,18 @@ window.__ModuleLoader__.load({
6749
10374
  children: scenery,
6750
10375
  })
6751
10376
  : null,
10377
+ showPet
10378
+ ? jsx('div', {
10379
+ className: `tg-debug${petLineIsWarning(petLine) ? ' tg-warn' : ''}`,
10380
+ children: petLine,
10381
+ })
10382
+ : null,
10383
+ showMood
10384
+ ? jsx('div', {
10385
+ className: `tg-debug${moodLineIsWarning(moodLine) ? ' tg-warn' : ''}`,
10386
+ children: moodLine,
10387
+ })
10388
+ : null,
6752
10389
  ],
6753
10390
  })
6754
10391
  : null,
@@ -6792,6 +10429,23 @@ window.__ModuleLoader__.load({
6792
10429
  /** Display name used in client diagnostics. */
6793
10430
  exports.name = 'theme-gallery'
6794
10431
 
10432
+ /**
10433
+ * 心情问候的座位:shell.overlay 槽位里的一张静态 div。
10434
+ *
10435
+ * 零 hooks、零 props——问候条的几何/文本/速度全由 mood 模块命令式管理
10436
+ * (见 moodBuildBar),这张 div 只是给 React 一个可卸载的挂载点。
10437
+ * `data-dsh-mood-seat` 是 MOOD_SEAT_SELECTOR 找它的稳定钩子(自己的数据属性,
10438
+ * 不依赖框架的哈希类名——SKILL §13 的老规矩)。
10439
+ * @returns the seat element.
10440
+ */
10441
+ function MoodSeat() {
10442
+ return jsx('div', {
10443
+ className: 'dsh-mood-seat',
10444
+ 'data-dsh-mood-seat': '',
10445
+ 'aria-hidden': 'true',
10446
+ })
10447
+ }
10448
+
6795
10449
  /**
6796
10450
  * The HARD dependency list — deliberately ONE entry.
6797
10451
  *
@@ -6822,17 +10476,26 @@ window.__ModuleLoader__.load({
6822
10476
  * That is the pair that makes it safe, and this package now has both:
6823
10477
  *
6824
10478
  * package.json dsh.client.inject = ["@deepseek-ai/dsh-client-locale",
10479
+ * "@deepseek-ai/dsh-client-ui-layout",
6825
10480
  * "@deepseek-ai/dsh-client-ui-theme"]
6826
- * here exports.inject = ['slots', 'locale', 'theme']
10481
+ * here the inject list = ['slots', 'locale', 'theme', 'layout']
6827
10482
  *
6828
10483
  * The two earlier failures were the `modifies` cycle and the missing module declaration.
6829
- * Both are fixed, and both are asserted in `tests/check-boot-safety.mjs`.
10484
+ * Both are fixed, and both are asserted in `tests/check-boot-safety.mjs`. `layout` joined
10485
+ * in 2026-09-27 for the settings-section jump: without the declaration `ctx.layout` is
10486
+ * undefined and the "open panel" button silently only closed the dialog (实测读数).
10487
+ * Note: the assertion regex is anchored to line start — prose examples like this one
10488
+ * must NOT be written as `exports.inject = [...]`, or the static audit reads the comment.
6830
10489
  *
6831
10490
  * `locale` is declared for the same reason: the sidebar row resolves its label through it,
6832
10491
  * and the providing module is declared in the manifest.
6833
10492
  * @type {string[]}
6834
10493
  */
6835
- exports.inject = ['slots', 'locale', 'theme']
10494
+ // `layout` 是设置分节「打开主题皮肤面板」的跳转面(ctx.layout.selectPanel —— 官方
10495
+ // Context 合并声明,契约原文说它就是给其他插件做面板切换的横切面)。实机读数
10496
+ // 2026-09-27:没声明它时 ctx.layout 是 undefined,跳转按钮只关了弹窗 —— 插件
10497
+ // 能读到哪些服务完全由这份清单决定,缺一个就是静默失败。
10498
+ exports.inject = ['slots', 'locale', 'theme', 'layout']
6836
10499
 
6837
10500
  /**
6838
10501
  * Marks this client module as a PLUGIN rather than a plain service module.
@@ -7578,11 +11241,25 @@ window.__ModuleLoader__.load({
7578
11241
  // The registry keeps the order things were registered in, so re-ranking a card can
7579
11242
  // never change which themes exist, what they contain, or the order they were
7580
11243
  // contributed — `cardsInDisplayOrder` is a view over the filtered list.
7581
- const themes = cardsInDisplayOrder([...snapshot.themes].filter((theme) => !OMITTED_IDS.has(theme.id)))
11244
+ // 宠物挂件卡不是主题、不在注册表里:作为一张**只存在于面板**的卡片数据追加进来,
11245
+ // 与其他卡走同一条排序管线(见 `petWidgetCardTheme`)。菜单折叠卡同构。
11246
+ const themes = cardsInDisplayOrder([...snapshot.themes].filter((theme) => !OMITTED_IDS.has(theme.id))
11247
+ .concat([petWidgetCardTheme(), moodCardTheme(), menuCollapseCardTheme()]))
7582
11248
  storeActions.sync(themes, appliedCardId(snapshot), revision)
7583
11249
  // 卡片上点亮哪一格,以及要不要把那一层配色叠上去 —— 两件事都读同一份
7584
11250
  // localStorage,所以"看到的选中态"与"屏幕上的颜色"不可能对不上。
7585
11251
  storeActions.markScheme(wantedScheme())
11252
+ // 挂件卡的徽标读同一个来源:localStorage 里记着的开关状态。
11253
+ storeActions.markPet(petWidgetOn())
11254
+ // 头像选择行的点亮态与卡面图案也读同一个来源:当前是哪只宠物。
11255
+ storeActions.markPetKind(readPetState().kind)
11256
+ // 心情问候卡的胶囊开关与三组 chips 读同一个来源。
11257
+ storeActions.markMood(readMoodState())
11258
+ // 菜单折叠卡的徽标与 aria-pressed 读同一个来源。
11259
+ storeActions.markMenuCollapse(menuCollapsed())
11260
+ // 问候条是幂等同步:状态/几何/文本没变就一个字节都不写(moodBuildBar 指纹守卫),
11261
+ // 所以 publish 顺路调用它没有性能负担,还负责把"座位晚渲染"捡起来。
11262
+ syncMoodBar()
7586
11263
  syncSkin(snapshot)
7587
11264
  syncRememberedSkin(snapshot)
7588
11265
  syncScheme(snapshot)
@@ -7649,6 +11326,109 @@ window.__ModuleLoader__.load({
7649
11326
  }
7650
11327
  }
7651
11328
 
11329
+ /**
11330
+ * 用户点了挂件卡(或卡上的开关动作):翻转开关、同步舞台、刷新面板。
11331
+ *
11332
+ * 这条路**完全不碰主题服务** —— 挂件与当前是什么皮肤正交,所以它能与任何皮肤
11333
+ * (包括山青婷彩 / 梦海游鱼)同时开启,也不消耗主题写入预算。
11334
+ * `publish()` 会把新状态经 `markPet` 报给页面,徽标与 aria-pressed 随之更新。
11335
+ */
11336
+ function togglePetWidget() {
11337
+ try {
11338
+ const next = !petWidgetOn()
11339
+ savePetState({ on: next })
11340
+ petRun.enabled = next
11341
+ syncPetStage()
11342
+ publish(ctx.theme.getTheme())
11343
+ } catch (error) {
11344
+ console.error('[theme-gallery] could not toggle the pet widget:', error)
11345
+ }
11346
+ }
11347
+
11348
+ /**
11349
+ * 用户点了心情问候卡(或卡上的胶囊开关):翻转开关、同步问候条、刷新面板。
11350
+ *
11351
+ * 与 `togglePetWidget` 同一条路:**完全不碰主题服务** —— 问候与当前是什么皮肤
11352
+ * 正交;`publish()` 会把新状态经 `markMood` 报给页面,徽标与 aria-checked 随之更新。
11353
+ */
11354
+ function toggleMoodWidget() {
11355
+ try {
11356
+ const next = !readMoodState().on
11357
+ saveMoodState({ on: next })
11358
+ syncMoodBar()
11359
+ publish(ctx.theme.getTheme())
11360
+ } catch (error) {
11361
+ console.error('[theme-gallery] could not toggle the mood greeting:', error)
11362
+ }
11363
+ }
11364
+
11365
+ /**
11366
+ * 用户点了问候卡上的一枚设置 chip(速度/方向/区域)。
11367
+ * 只认表里真实存在的值(MOOD_SPEEDS / ltr-rtl / MOOD_ZONE_GEOMETRY),
11368
+ * 不认识的静默丢 —— 与 chooseScheme 拒绝不认识方案 id 同一条规矩:
11369
+ * 写进 localStorage 会让下次启动带着一个点不亮的选中态。
11370
+ * @param patch - `{ speed? , direction?, zone? }` 的部分字段。
11371
+ */
11372
+ function setMoodSettings(patch) {
11373
+ try {
11374
+ const clean = {}
11375
+ if (patch !== null && typeof patch === 'object') {
11376
+ if (typeof patch.speed === 'string' && patch.speed in MOOD_SPEEDS) clean.speed = patch.speed
11377
+ if (patch.direction === 'ltr' || patch.direction === 'rtl') clean.direction = patch.direction
11378
+ if (typeof patch.zone === 'string' && patch.zone in MOOD_ZONE_GEOMETRY) clean.zone = patch.zone
11379
+ }
11380
+ if (Object.keys(clean).length === 0) return
11381
+ saveMoodState(clean)
11382
+ syncMoodBar()
11383
+ publish(ctx.theme.getTheme())
11384
+ } catch (error) {
11385
+ console.error('[theme-gallery] could not set the mood settings:', error)
11386
+ }
11387
+ }
11388
+
11389
+ /**
11390
+ * 用户点了菜单折叠卡:翻转折叠状态、收放侧栏入口、刷新面板。
11391
+ *
11392
+ * 这条路**完全不碰主题服务** —— 折叠只改侧栏入口的显隐,面板本体(main 槽位)
11393
+ * 与设置分节(settings.section)恒在,所以从任何一侧都能把开关翻回来。
11394
+ * `publish()` 会把新状态经 `markMenuCollapse` 报给页面。
11395
+ */
11396
+ function toggleMenuCollapse() {
11397
+ try {
11398
+ saveMenuCollapseState({ collapsed: !menuCollapsed() })
11399
+ syncSidebarEntry()
11400
+ publish(ctx.theme.getTheme())
11401
+ } catch (error) {
11402
+ console.error('[theme-gallery] could not toggle the menu collapse:', error)
11403
+ }
11404
+ }
11405
+
11406
+ /**
11407
+ * 用户点了挂件卡上的一颗头像:换成那只宠物并放它出场。
11408
+ *
11409
+ * 语义(需求 R2):挂件关着时点头像 = 顺路**打开**("点哪只出哪只",即时反馈);
11410
+ * 开着时 = 只换宠物。`placed` 一并复位 —— 不同宠物尺寸不同,继承上一只的
11411
+ * 视口偏移会错位,切宠即按新宠物的缺省摆位重新入座、跑向自己的道具。
11412
+ * 这条路与 `togglePetWidget` 一样**完全不碰主题服务**。
11413
+ * @param kindId - 宠物 id(不认识的静默拒绝,照 chooseScheme 的规矩)。
11414
+ */
11415
+ function selectPetWidget(kindId) {
11416
+ try {
11417
+ if (!PET_KIND_BY_ID.has(kindId)) return
11418
+ const turningOn = !petWidgetOn()
11419
+ const patch = { kind: kindId, placed: false }
11420
+ if (turningOn) patch.on = true
11421
+ savePetState(patch)
11422
+ petRun.enabled = true
11423
+ removePetStage()
11424
+ petNoteInteraction()
11425
+ syncPetStage()
11426
+ publish(ctx.theme.getTheme())
11427
+ } catch (error) {
11428
+ console.error('[theme-gallery] could not select the pet:', error)
11429
+ }
11430
+ }
11431
+
7652
11432
  /**
7653
11433
  * 卡片上「已应用」徽标应该落在哪一张卡上。
7654
11434
  *
@@ -7833,6 +11613,9 @@ window.__ModuleLoader__.load({
7833
11613
  }
7834
11614
  const active = lastSnapshot?.active?.id
7835
11615
  const kind = typeof active === 'string' ? bundledTheme(active)?.ambient?.kind : undefined
11616
+ // 宠物挂件不依赖侧栏,只锚输入框:放在侧栏存在性判断**之前**,节流与本帧合并
11617
+ // 共用(resync 至多每帧一次)。未开启时它第一行就返回,成本是一次布尔判断。
11618
+ syncPetStage()
7836
11619
  if (sidebarColumn() === null) {
7837
11620
  // Recorded even though there is nothing to draw. An attempt that finds no sidebar
7838
11621
  // is the likeliest explanation for boot-time silence, and the old report could not
@@ -8000,6 +11783,35 @@ window.__ModuleLoader__.load({
8000
11783
  // shipped panel plugins use the plain form, so this does too — the extra
8001
11784
  // machinery was unverified, and a subscription that never installs is
8002
11785
  // indistinguishable from a panel that never registers.
11786
+ /**
11787
+ * 面板的 inject 面:侧栏页与设置分节(MenuSettingsSection)共用的同一份动作。
11788
+ * **刻意零主题写入的新增** —— setTheme 是外壳槽位动作(审计允许清单精确匹配
11789
+ * 的那一条),其余全部只写 localStorage。
11790
+ * @returns 动作面(含 pinned store,供设置分节的 useSyncExternalStore 订阅)。
11791
+ */
11792
+ function buildPanelFace() {
11793
+ return {
11794
+ setTheme: (id) => { ctx.theme.setTheme(id) },
11795
+ // 配色卡上的 15 个色值按钮走这条。它**不直接**切主题(见 `chooseScheme`),
11796
+ // 所以这里不新增主题写入点。
11797
+ pickScheme: (schemeId) => { chooseScheme(schemeId) },
11798
+ // 挂件卡走这条:只开关挂件、只写 localStorage,同样不新增主题写入点。
11799
+ togglePet: () => { togglePetWidget() },
11800
+ // 头像选择行走这条:换宠物(必要时顺路打开),同样零主题写入。
11801
+ pickPet: (kindId) => { selectPetWidget(kindId) },
11802
+ // 心情问候卡走这两条:胶囊开关/点卡身开关、三组设置 chips。
11803
+ // 一样零主题写入 —— 问候与皮肤正交。
11804
+ toggleMood: () => { toggleMoodWidget() },
11805
+ setMood: (patch) => { setMoodSettings(patch) },
11806
+ // 菜单折叠卡走这条:收放侧栏入口、只写 localStorage,同样不新增主题写入点。
11807
+ toggleMenuCollapse: () => { toggleMenuCollapse() },
11808
+ // 设置分节组件用:pinned store 实例(subscribe/getSnapshot —— StoreInstance
11809
+ // 契约上没有 getState,白屏事故的根因就是按 getState 读的),MenuSettingsSection
11810
+ // 经 useSyncExternalStore 把它绑定成响应式视图。
11811
+ store: storeInstance,
11812
+ }
11813
+ }
11814
+
8003
11815
  ctx.slots.inject('main', () => {
8004
11816
  const disposePage = ctx.slots.register({
8005
11817
  name: 'main',
@@ -8014,12 +11826,7 @@ window.__ModuleLoader__.load({
8014
11826
  // — so it is set BEFORE the publish that reads it.
8015
11827
  injectRan = true
8016
11828
  publish(ctx.theme.getTheme())
8017
- return {
8018
- setTheme: (id) => { ctx.theme.setTheme(id) },
8019
- // 配色卡上的 15 个色值按钮走这条。它**不直接**切主题(见 `chooseScheme`),
8020
- // 所以这里不新增主题写入点。
8021
- pickScheme: (schemeId) => { chooseScheme(schemeId) },
8022
- }
11829
+ return buildPanelFace()
8023
11830
  },
8024
11831
  }, ThemeGalleryPage)
8025
11832
 
@@ -8062,13 +11869,76 @@ window.__ModuleLoader__.load({
8062
11869
  // Sidebar entrance: the list id IS the main-panel key. `locale` is declared
8063
11870
  // the way the shipped panel entries declare it, so the row label resolves
8064
11871
  // through the dictionary rather than a raw string.
8065
- ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
8066
- name: 'sidebar.panellist',
8067
- id: PANEL_ID,
8068
- order: 30,
11872
+ // 菜单折叠(方案二):注册/撤销随折叠开关走 —— syncSidebarEntry 读 localStorage
11873
+ // 决定是否注册;设置页的常驻分节不受它影响(面板永远可达,不会把自己锁在外面)。
11874
+ syncSidebarEntry()
11875
+
11876
+ /**
11877
+ * 一级设置分节(`settings.section`):完整面板常驻于 设置 → 分节列表。
11878
+ * 槽位契约(官方 d.ts):id = 分节键、order = 导航位置、label = 注册方本地化文案、
11879
+ * 内容组件 + inject 面由注册方自带。order 的标定读数与"agent预设下方"的对位
11880
+ * 见 SETTINGS_SECTION_ORDER 的文档。
11881
+ */
11882
+ ctx.slots.inject('settings.section', () => {
11883
+ try {
11884
+ const unregister = ctx.slots.register({
11885
+ name: 'settings.section',
11886
+ id: PANEL_ID,
11887
+ order: SETTINGS_SECTION_ORDER,
11888
+ label: () => zh.title,
11889
+ locale: NS,
11890
+ inject: () => buildPanelFace(),
11891
+ }, MenuSettingsSection)
11892
+ return () => { unregister() }
11893
+ } catch (error) {
11894
+ // 分节注册失败绝不拖垮挂载(面板仍在侧栏/主区可达);失败本身要留痕。
11895
+ console.error('[theme-gallery] could not register the settings section:', error)
11896
+ return () => {}
11897
+ }
11898
+ })
11899
+
11900
+ /* ---------------- mood greeting seat ----------------
11901
+ *
11902
+ * 问候条本体由 mood 模块命令式地建进这张座位(`shell.overlay` 是官方扩展点:
11903
+ * 布局壳在 AppFrame 最后渲染 `[data-shell-overlay]`,quota 提示、快捷键弹窗等
11904
+ * 五个官方包都往里注册组件)。座位是一张**静态 div**:零 hooks(本插件只引
11905
+ * jsx/jsxs,不引 react),一切状态由 localStorage 报给 store、由 publish 驱动
11906
+ * syncMoodBar。`aria-hidden`:滚动文字对读屏是噪音,装饰读数走面板自检行。
11907
+ *
11908
+ * 为什么不直接 append 到 body(挂件的做法):overlay 条目随外壳的 React 生命
11909
+ * 周期走,外壳重组合时不会留下孤儿条;"座位还没渲染"由 moodReport 留读数、
11910
+ * 下一次 publish 再捡,不轮询。
11911
+ *
11912
+ * 注入回调返回的清理函数在插件卸载时收尾全部计时器/监听器/样式表 ——
11913
+ * **刻意不叫 `…Dispose`**:那是自发光守卫审计里主题层专用模式的名字(规则 1)。
11914
+ */
11915
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
11916
+ name: 'shell.overlay',
11917
+ id: 'theme-gallery:mood',
8069
11918
  locale: NS,
8070
- label: () => zh.title,
8071
- }, PanelGlyph))
11919
+ inject: () => ({}),
11920
+ }, MoodSeat))
11921
+
11922
+ /* ---------------- pet widget ----------------
11923
+ *
11924
+ * 启用状态在挂载时读一次 localStorage(存进 petRun.enabled 作镜像,避免 resync
11925
+ * 每帧都读存储),开着就把舞台建起来;关闭时什么都不装。卸载时整层拆掉 ——
11926
+ * 舞台、样式表与全部定时器都在 removePetStage 里收尾。
11927
+ */
11928
+ ctx.effect(() => {
11929
+ if (typeof document === 'undefined') return
11930
+ petRun.enabled = petWidgetOn()
11931
+ if (petRun.enabled) syncPetStage()
11932
+ return () => { removePetStage() }
11933
+ }, 'theme-gallery: pet widget')
11934
+
11935
+ /* 心情问候:挂载即首建(座位可能还没渲染——syncMoodBar 会留读数,等 publish
11936
+ * 顺路再捡),卸载时收尾全部计时器/监听器/条/样式表。 */
11937
+ ctx.effect(() => {
11938
+ if (typeof document === 'undefined') return
11939
+ syncMoodBar()
11940
+ return () => { moodTeardown() }
11941
+ }, 'theme-gallery: mood greeting')
8072
11942
 
8073
11943
  /* ---------------- reading state ---------------- */
8074
11944