@starwish-ai/xingyuan-dsh 0.5.0 → 0.5.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,68 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.2] - 2026-08-27
9
+
10
+ ### Added
11
+ - **Controllable conversation-view tabs**: the six tabs (Today / Wishes / Tasks /
12
+ Calendar / Growth / Memory) are no longer always present. Default mode **follows the
13
+ session preset** — tabs appear only in sessions composed from the `xingyuan` preset and
14
+ stay out of every other conversation. Settings → XingYuan gains a **Tab visibility**
15
+ card with three modes (`Follow session` / `Always show` / `Always hide`) plus
16
+ per-tab toggles (`hiddenTabs`, all shown by default). The preference namespace
17
+ (`xingyuan-ui`) is installed by the bundle layer, so it works before any XingYuan
18
+ session exists and stays reachable even when tabs are fully hidden (a preset-layer
19
+ namespace would be unavailable exactly when needed). Today's page shows a one-line
20
+ notice when "Always show" puts the tabs into a non-XingYuan session (pages stay fully
21
+ browsable and operable; only the chat-side capabilities are absent). Read-only URL
22
+ pages (`/xingyuan/*`) and chat cards are unaffected.
23
+ - New tab-visibility policy (`visibleTabIds`) with dirty-value tolerance (unknown/duplicate
24
+ hidden ids are ignored); covered by `test/tab-policy.test.ts`.
25
+
26
+ ### Fixed
27
+ - Client boot crash: the tab-visibility controller subscribed to the injected `sessions`
28
+ service itself, which has no `subscribe` — the session-list snapshot lives on
29
+ `sessions.list`. The client half failed to load entirely ("Failed to load plugins"),
30
+ which also hid the Settings → XingYuan card. Subscriptions now target
31
+ `sessions.list` (`getSnapshot`/`subscribe`).
32
+ - Tab-visibility toggles had inverted semantics: the check chips rendered "hidden" as
33
+ "checked", so ticking a tab produced no change. `shown` now reads
34
+ `!hiddenTabs.includes(id)` (checked = shown).
35
+
36
+ ## [0.5.1] - 2026-08-27
37
+
38
+ Patch release. Two hardening changes: sessions that contain `xingyuan/*` card events
39
+ now survive cold loads after a restart, and the client stylesheet no longer depends on
40
+ `color-mix()` support in the hosting browser. No schema changes; existing data stays
41
+ compatible.
42
+
43
+ ### Added
44
+ - Session-log self-repair (`session-log-repair.ts`): at activation the bundle scans
45
+ `$DSH_HOME/sessions` and adds `"ignorable": true` to `xingyuan/*` event lines, so
46
+ conversations containing cards are no longer rejected wholesale by the session format
47
+ reader after a process restart. Files without xingyuan events are never written;
48
+ non-xingyuan lines keep their exact bytes; originals are backed up before the first
49
+ rewrite (3 kept per session); torn tails, corrupt frames, version mismatches,
50
+ unparsable lines, and live sessions are skipped whole-file. A per-session marker
51
+ records the artifact byte size, so already-repaired sessions skip the decompress scan
52
+ at the next startup (68 sessions: 3.6s → <10ms). Containers whose first zstd frame is
53
+ not exactly one header line — a legacy single-frame layout that broke host startup —
54
+ are rewritten into the valid layout even when no marking is needed.
55
+ - New bundle config `repairSessionLogs` (default `true`) to disable the self-repair pass.
56
+
57
+ ### Changed
58
+ - Client styles no longer use `color-mix()`; derived colors are explicit per-theme rgba
59
+ tokens (`--xyd-*-border/ring/hatch/hover`). Empty and error states are plain text
60
+ layouts — the SVG line-art illustrations whose strokes relied on `color-mix()` are
61
+ removed — and the growth hero gradient is pre-mixed in JS (`darkenHex`).
62
+
63
+ ### Fixed
64
+ - Conversations containing `xingyuan/*` card events could not be reopened after a
65
+ process restart (`SessionFormatUnsupportedError`); they are now self-healed at
66
+ activation and replay their cards again.
67
+ - In browsers without `color-mix()` support, decorative strokes (empty-state art, hover
68
+ rings, hatch fills, danger buttons) silently vanished, leaving isolated accent dots.
69
+
8
70
  ## [0.5.0] - 2026-08-27
9
71
 
10
72
  Write-path unification and read-side freshness alignment between the chat tools,
package/README.en-US.md CHANGED
@@ -14,7 +14,7 @@ XingYuan is a wish & habit-building companion, shipped as a DeepSeek Harness plu
14
14
  - **Opportunity-day check-ins**: check-in, make-up, cancel, and future pre-checks; progress always recomputed against today
15
15
  - **Micro-actions**: break an overwhelming goal into 3–7 tiny steps and walk through them guided
16
16
  - **Growth system**: levels Lv.1–Lv.10, streak bonuses, stat cards, 30-day bar chart
17
- - **Session view tabs**: Today / Wishes / Tasks / Calendar / Growth / Memory — buttons call action endpoints directly
17
+ - **Session view tabs**: Today / Wishes / Tasks / Calendar / Growth / Memory — buttons call action endpoints directly; shown only in XingYuan-preset sessions by default, switchable to always show/hide and per-tab in Settings
18
18
  - **Memory**: important memories injected into context (limit configurable), full CRUD
19
19
  - **Charts**: 15 chart types rendered as cards
20
20
  - **Safe writes**: write operations confirmed by default (configurable); deletion always confirmed
@@ -42,6 +42,7 @@ Business data lives at `~/.dsh/xingyuan/xingyuan.sqlite` and survives uninstall
42
42
  | User profile | Nickname, occupation, interests |
43
43
  | Write confirmation | Whether create / check-in writes need confirmation (deletion always does) |
44
44
  | Memory injection limit | Max memories injected per turn |
45
+ | Tab visibility | Three modes (follow session / always show / always hide) plus per-tab toggles; defaults to follow session |
45
46
 
46
47
  ## Development
47
48
 
package/README.md CHANGED
@@ -14,7 +14,8 @@
14
14
  - **机会日打卡**:周期任务打卡、补卡、取消、未来预勾,进度跨天恒为最新
15
15
  - **微行动拆解**:把无从下手的目标拆成 3–7 个小步,引导式逐个完成或跳过
16
16
  - **成长体系**:等级 Lv.1–Lv.10、连击加成、统计卡与近 30 天柱状图
17
- - **会话视图页**:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆 六个标签页,页面按钮直连动作接口
17
+ - **会话视图页**:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆 六个标签页,页面按钮直连动作接口;
18
+ 默认仅星愿预设的会话显示,可在设置里切换「始终显示 / 始终隐藏」并按标签勾选
18
19
  - **记忆**:重要记忆自动注入上下文(上限可配),支持增删改查
19
20
  - **图表**:15 种统计图表自动渲染为卡片
20
21
  - **安全确认**:写操作默认二次确认(可关闭),删除始终确认
@@ -42,6 +43,7 @@ dsh plugin --profile web add @starwish-ai/xingyuan-dsh
42
43
  | 用户画像 | 昵称、职业、兴趣 |
43
44
  | 写操作二次确认 | 创建 / 打卡类写操作是否确认(删除始终确认) |
44
45
  | 记忆注入上限 | 每次注入上下文的记忆条数上限 |
46
+ | 标签页显示 | 三态模式(跟随会话 / 始终显示 / 始终隐藏)+ 六个标签逐个勾选;默认跟随会话 |
45
47
 
46
48
  ## 开发
47
49
 
@@ -235,7 +235,16 @@ const ZH_DICT = {
235
235
  'settings.pref.memoryLimit': '记忆注入上限',
236
236
  'settings.pref.memoryLimitHint': '每次对话自动注入上下文的记忆条数上限(5-200,默认 40);失焦后保存。',
237
237
  'settings.pref.limitInvalid': '请输入 5-200 的整数',
238
+ 'settings.tabs.title': '标签页显示',
239
+ 'settings.tabs.mode.follow': '跟随会话',
240
+ 'settings.tabs.mode.show': '始终显示',
241
+ 'settings.tabs.mode.hide': '始终隐藏',
242
+ 'settings.tabs.chooseTab': '显示的标签页',
243
+ 'settings.tabs.loading': '标签页选项加载中…',
244
+ 'settings.tabs.unavailable': '当前连接不支持持久化设置,标签页显示选项不可用。',
245
+ 'settings.tabs.hint': '跟随会话:仅星愿预设的会话显示标签页;其他会话自动隐藏。可单独勾选/取消各标签。',
238
246
  'settings.dataHint': '业务数据存于本机 ~/.dsh/xingyuan/,备份即拷贝该目录。',
247
+ 'today.noPresetHint': '当前会话未启用星愿预设:页面数据可浏览、操作可用,但对话中的星愿能力不可用。',
239
248
  // --- 微行动卡 ---
240
249
  'micro.cleared': '已清除「{task}」的拆解',
241
250
  'micro.finished': '「{task}」微行动完成(可打卡了)',
@@ -532,7 +541,16 @@ const EN_DICT = {
532
541
  'settings.pref.memoryLimit': 'Memory injection limit',
533
542
  'settings.pref.memoryLimitHint': 'Max memories auto-injected per chat (5-200, default 40); saved on blur.',
534
543
  'settings.pref.limitInvalid': 'Enter an integer between 5 and 200',
544
+ 'settings.tabs.title': 'Tab visibility',
545
+ 'settings.tabs.mode.follow': 'Follow session',
546
+ 'settings.tabs.mode.show': 'Always show',
547
+ 'settings.tabs.mode.hide': 'Always hide',
548
+ 'settings.tabs.chooseTab': 'Visible tabs',
549
+ 'settings.tabs.loading': 'Loading tab options…',
550
+ 'settings.tabs.unavailable': 'This connection cannot persist settings; tab visibility options are unavailable.',
551
+ 'settings.tabs.hint': 'Follow session: tabs appear only in sessions using the XingYuan preset; other sessions hide them automatically. Toggle individual tabs below.',
535
552
  'settings.dataHint': 'Data lives locally at ~/.dsh/xingyuan/ — back it up by copying the folder.',
553
+ 'today.noPresetHint': 'This session is not using the XingYuan preset: pages stay browsable and usable, but XingYuan chat capabilities are unavailable.',
536
554
  'micro.cleared': 'Cleared the micro-step plan of “{task}”',
537
555
  'micro.finished': 'Micro-steps of “{task}” finished (ready to check in)',
538
556
  'micro.step': '“{task}” micro-steps · step {current}/{total}',
@@ -2,7 +2,7 @@ import { t, XY_NS, setupLocale } from './i18n.js';
2
2
  import { disposeConfirms, disposeToasts } from './ui.js';
3
3
  import { STYLE_TEXT } from './styles.js';
4
4
  import { CARD_VIEWS } from './cards.js';
5
- import { CalendarPage, GrowthPage, MemoryPage, TasksPage, TodayPage, WishesPage } from './pages/index.js';
5
+ import { installTabVisibility } from './tab-visibility.js';
6
6
  import { SettingsSection } from './pages/settings.js';
7
7
  export const inject = ['slots', 'settingsScope', 'conversationEvents'];
8
8
  /** 事件类型 → 渲染 kind。 */
@@ -73,19 +73,16 @@ export function apply(ctx) {
73
73
  ctx.slots.register({ name: 'conversation.chat.node', key: 'xy-chart' }, CARD_VIEWS.ChartView),
74
74
  ctx.slots.register({ name: 'conversation.chat.node', key: 'xy-micro' }, CARD_VIEWS.MicroView),
75
75
  ]);
76
- // 会话视图环标签页:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆(仅激活时挂载,进入即取最新数据)。
76
+ // 会话视图环标签页:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆。
77
+ // 显隐由 tab-visibility 控制器按「界面偏好设置 × 会话预设」动态维护:
78
+ // 默认跟随会话(星愿预设的会话才显示),设置可切始终显示/始终隐藏并按标签勾选。
77
79
  // label 用 thunk 跟随当前语言;locale 声明让壳在语言切换时刷新标签行。
78
- ctx.slots.inject('conversation.view', () => [
79
- ctx.slots.register({ name: 'conversation.view', id: 'xy-today', order: 21, label: () => t('tab.today'), locale: XY_NS }, TodayPage),
80
- ctx.slots.register({ name: 'conversation.view', id: 'xy-wishes', order: 22, label: () => t('tab.wishes'), locale: XY_NS }, WishesPage),
81
- ctx.slots.register({ name: 'conversation.view', id: 'xy-tasks', order: 23, label: () => t('tab.tasks'), locale: XY_NS }, TasksPage),
82
- ctx.slots.register({ name: 'conversation.view', id: 'xy-calendar', order: 24, label: () => t('tab.calendar'), locale: XY_NS }, CalendarPage),
83
- ctx.slots.register({ name: 'conversation.view', id: 'xy-growth', order: 25, label: () => t('tab.growth'), locale: XY_NS }, GrowthPage),
84
- ctx.slots.register({ name: 'conversation.view', id: 'xy-memory', order: 26, label: () => t('tab.memory'), locale: XY_NS }, MemoryPage),
85
- ]);
86
- // 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限(设置命名空间)
80
+ ctx.slots.inject('conversation.view', () => installTabVisibility(ctx));
81
+ // 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
82
+ // (preset 命名空间)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,未选星愿也可调)
87
83
  ctx.slots.inject('settings.section', () => {
88
84
  const scope = ctx.settingsScope.bind({ namespace: 'xingyuan' });
89
- return ctx.slots.register({ name: 'settings.section', id: 'xingyuan', order: 60, label: () => t('settings.tabLabel'), locale: XY_NS }, () => SettingsSection({ scope }));
85
+ const uiscope = ctx.settingsScope.bind({ namespace: 'xingyuan-ui' });
86
+ return ctx.slots.register({ name: 'settings.section', id: 'xingyuan', order: 60, label: () => t('settings.tabLabel'), locale: XY_NS }, () => SettingsSection({ scope, uiscope }));
90
87
  });
91
88
  }
@@ -12,6 +12,13 @@ const LEVEL_TINTS = ['#475569', '#2563eb', '#047857', '#0e7490', '#0369a1', '#1d
12
12
  function levelTint(level) {
13
13
  return LEVEL_TINTS[Math.min(LEVEL_TINTS.length, Math.max(1, level)) - 1] ?? LEVEL_TINTS[0];
14
14
  }
15
+ /** 按比例调暗 #RRGGBB 色值(等价于 color-mix(in srgb, tint ratio%, #000))。
16
+ * 用 JS 预混而非 CSS color-mix():后者在不支持的浏览器里整条声明失效(AGENTS.md §5.10 兼容铁律)。 */
17
+ export function darkenHex(hex, ratio) {
18
+ const value = parseInt(hex.slice(1), 16);
19
+ const channel = (shift) => Math.round(((value >> shift) & 0xff) * ratio);
20
+ return `#${((1 << 24) | (channel(16) << 16) | (channel(8) << 8) | channel(0)).toString(16).slice(1)}`;
21
+ }
15
22
  export function GrowthPage() {
16
23
  const t = useXyT();
17
24
  const stabilize = useStableScrollbar();
@@ -92,8 +99,9 @@ export function GrowthPage() {
92
99
  return createElement('div', { className: 'xy-page', ref: stabilize }, createElement('div', {
93
100
  className: 'xy-hero',
94
101
  // 实色双档渐变(78% 混黑深档 → 基档):白色文字对底色对比 ≥4.5:1 在两种壳主题下
95
- // 都恒成立;半透明渐变(旧版 tint+d9/tint+73)在浅色壳会混入页面白底,右端跌破 3:1
96
- style: { background: `linear-gradient(135deg, color-mix(in srgb, ${tint} 76%, #000), ${tint})` },
102
+ // 都恒成立;半透明渐变(旧版 tint+d9/tint+73)在浅色壳会混入页面白底,右端跌破 3:1。
103
+ // 深档用 darkenHex 预混(曾用 color-mix 内联,兼容性见 styles.ts 头注铁律)
104
+ style: { background: `linear-gradient(135deg, ${darkenHex(tint, 0.76)}, ${tint})` },
97
105
  }, createElement('div', { className: 'xy-herobadge', style: { background: tint } }, `Lv.${levelNumber}`), createElement('div', { className: 'xy-heromain' }, createElement('div', { className: 'xy-meta xy-onhero' }, t('growth.levelLabel')), createElement('h2', { className: 'xy-herotitle' }, `Lv.${levelNumber} · ${g.levelName ?? t('growth.levelFallback')}`), createElement('div', { className: 'xy-bar xy-bar-onhero' }, createElement('div', { className: 'xy-bar-fill xy-bar-fill-solid', style: { transform: `scaleX(${Math.min(Math.max(g.levelProgress ?? 0, 0), 100) / 100})` } })), createElement('div', { className: 'xy-meta xy-onhero' }, expText))), g.rewardDescription !== undefined
98
106
  ? createElement('div', { className: 'xy-meta xy-heroreward' }, t('growth.rewardPrefix', { reward: g.rewardDescription }))
99
107
  : null, createElement('div', { className: 'xy-stats' }, stat(g.totalCheckinDays ?? g.totalCheckins ?? 0, t('growth.stat.checkinDays')), stat(g.currentStreak, t('growth.stat.streak'), 'hot'), stat(g.maxStreak, t('growth.stat.maxStreak')), stat(g.wishTotal, t('growth.stat.wishTotal')), stat(g.wishAchieved, t('growth.stat.wishDone')), stat(g.taskTotal ?? t('growth.stat.none'), t('growth.stat.taskTotal'), g.taskTotal === undefined ? 'muted' : undefined), stat(g.taskAchieved ?? t('growth.stat.none'), t('growth.stat.taskDone'), g.taskAchieved === undefined ? 'muted' : undefined)), createElement('h3', { className: 'xy-section-title' }, t('growth.chart.title')),
@@ -244,9 +244,9 @@ export function MemoryPage() {
244
244
  }), notice !== undefined ? createElement('span', { className: 'xy-saved', role: 'status' }, notice) : null), data.total > allItems.length
245
245
  ? createElement('div', { className: 'xy-meta xy-memcap' }, t('memory.capNote', { total: data.total, shown: allItems.length }))
246
246
  : null, data.total === 0
247
- ? createElement(PageEmpty, { art: 'memory', title: t('memory.empty.title'), hint: t('memory.empty.hint') })
247
+ ? createElement(PageEmpty, { title: t('memory.empty.title'), hint: t('memory.empty.hint') })
248
248
  : filtered.length === 0
249
- ? createElement(PageEmpty, { art: 'search', title: t('memory.searchEmpty.title') })
249
+ ? createElement(PageEmpty, { title: t('memory.searchEmpty.title') })
250
250
  : // 列表收进单张分组卡:分隔线行替代逐行描边盒子,长列表不再满屏边框噪音
251
251
  createElement('section', { className: 'xy-group' }, createElement('ul', { className: 'xy-grouplist' }, rows)), hasMore
252
252
  ? createElement('div', { className: 'xy-memfoot' }, createElement('button', { className: 'xy-btn', disabled: loadingMore || busy, onClick: loadMore }, loadingMore ? `${t('common.loading')}…` : t('memory.more')), loadError !== undefined ? createElement('span', { className: 'xy-field-err' }, loadError) : null)
@@ -1,10 +1,27 @@
1
- /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限(设置命名空间)。 */
1
+ /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
2
+ * (preset 命名空间)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,未选星愿也可调)。 */
2
3
  import { createElement, useEffect, useRef, useState, useSyncExternalStore } from 'react';
3
4
  import { getJson, postJson } from '../api.js';
4
5
  import { toastError } from '../ui.js';
5
6
  import { useXyT, activeLocale } from '../i18n.js';
7
+ import { TAB_IDS } from '../../tab-policy.js';
6
8
  const COACH_IDS = ['gentle', 'strict', 'humorous'];
7
9
  const COACH_KEYS = ['settings.coach.gentle', 'settings.coach.strict', 'settings.coach.humorous'];
10
+ /** 显隐三态(顺序即分段按钮顺序;与 tab-policy 字面量同源)。 */
11
+ const MODE_OPTIONS = [
12
+ { mode: 'follow', key: 'settings.tabs.mode.follow' },
13
+ { mode: 'show', key: 'settings.tabs.mode.show' },
14
+ { mode: 'hide', key: 'settings.tabs.mode.hide' },
15
+ ];
16
+ /** 六个标签的显示名键(与 tab-visibility 的 entry labelKey 同源)。 */
17
+ const TAB_LABEL_KEYS = {
18
+ today: 'tab.today',
19
+ wishes: 'tab.wishes',
20
+ tasks: 'tab.tasks',
21
+ calendar: 'tab.calendar',
22
+ growth: 'tab.growth',
23
+ memory: 'tab.memory',
24
+ };
8
25
  export function SettingsSection(props) {
9
26
  const t = useXyT();
10
27
  const scope = props.scope;
@@ -12,6 +29,9 @@ export function SettingsSection(props) {
12
29
  const snap = useSyncExternalStore((listener) => scope.subscribe(listener), () => scope.getSnapshot());
13
30
  const writable = snap.status === 'ready' && snap.writable;
14
31
  const limit = String(snap.value?.memoryInjectLimit ?? 40);
32
+ // 界面偏好命名空间(标签页显隐)独立订阅:两个命名空间各自走自己的快照
33
+ const uisnap = useSyncExternalStore((listener) => props.uiscope.subscribe(listener), () => props.uiscope.getSnapshot());
34
+ const uiWritable = uisnap.status === 'ready' && uisnap.writable;
15
35
  // 教练风格与画像存于星愿数据库 global 单例(与对话侧工具同一数据源),经 /xingyuan/api/profile 读写
16
36
  const [profile, setProfile] = useState(undefined);
17
37
  const [profileError, setProfileError] = useState(undefined);
@@ -25,11 +45,44 @@ export function SettingsSection(props) {
25
45
  const [savedMsg, setSavedMsg] = useState('');
26
46
  // 二次确认开关:本地乐观值(undefined=跟随远端快照)——写入在途时 UI 立即响应,失败回滚
27
47
  const [pendingToggle, setPendingToggle] = useState(undefined);
48
+ // 标签页显隐:模式与勾选各自乐观(写入在途时禁用对应控件,失败回滚 + toast)
49
+ const [pendingMode, setPendingMode] = useState(undefined);
50
+ const [pendingHidden, setPendingHidden] = useState(undefined);
28
51
  const savedTimer = useRef(undefined);
29
52
  // 档案加载代数守卫:慢响应不得覆盖用户已开始的草稿
30
53
  const profileSeqRef = useRef(0);
31
54
  // 开关显示值:乐观本地值优先(写入在途),否则跟随远端快照
32
55
  const confirmWrites = pendingToggle !== undefined ? pendingToggle : snap.value?.confirmWrites !== false;
56
+ // 标签页显隐显示值:乐观优先,否则远端快照,再兜底 schema 默认
57
+ const tabMode = pendingMode ?? uisnap.value?.tabVisibilityMode ?? 'follow';
58
+ const hiddenTabs = pendingHidden ?? uisnap.value?.hiddenTabs ?? [];
59
+ /** 切换显隐模式:乐观写,失效即回滚(分段按钮视觉与持久化口径一致)。 */
60
+ const switchTabMode = (next) => {
61
+ if (!uiWritable || pendingMode !== undefined || next === tabMode)
62
+ return;
63
+ setPendingMode(next);
64
+ void props.uiscope.set('tabVisibilityMode', next)
65
+ .then(() => setPendingMode(undefined))
66
+ .catch((err) => {
67
+ setPendingMode(undefined);
68
+ toastError(err);
69
+ });
70
+ };
71
+ /** 勾选单个标签(勾选 = 显示):成员关系按 TAB_IDS 稳定序重算,写入整体数组。 */
72
+ const toggleTab = (id, willShow) => {
73
+ if (!uiWritable || pendingHidden !== undefined || tabMode === 'hide')
74
+ return;
75
+ const next = willShow
76
+ ? TAB_IDS.filter((tid) => tid !== id && hiddenTabs.includes(tid))
77
+ : TAB_IDS.filter((tid) => tid === id || hiddenTabs.includes(tid));
78
+ setPendingHidden(next);
79
+ void props.uiscope.set('hiddenTabs', next)
80
+ .then(() => setPendingHidden(undefined))
81
+ .catch((err) => {
82
+ setPendingHidden(undefined);
83
+ toastError(err);
84
+ });
85
+ };
33
86
  const loadProfile = () => {
34
87
  const seq = ++profileSeqRef.current;
35
88
  setProfileError(undefined);
@@ -182,5 +235,32 @@ export function SettingsSection(props) {
182
235
  },
183
236
  }), createElement('span', { className: 'xy-hint' }, t('settings.pref.memoryLimitHint')), limitError
184
237
  ? createElement('span', { id: 'xy-limit-error', className: 'xy-field-err', role: 'alert' }, t('settings.pref.limitInvalid'))
185
- : null)), createElement('p', { className: 'xy-hint' }, t('settings.dataHint')));
238
+ : null)),
239
+ // 标签页显示:模式三态(跟随会话/始终显示/始终隐藏)+ 六个标签勾选 chips。
240
+ // 与教练风格卡同一 xy-seg 视觉语法;「始终隐藏」时勾选区整组禁用置灰。
241
+ // 命名空间常驻于 bundle 层(未选星愿预设也可调),见 src/ui-settings.ts 头注。
242
+ createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.tabs.title')), uisnap.status === 'unavailable'
243
+ ? createElement('p', { className: 'xy-hint' }, t('settings.tabs.unavailable'))
244
+ : uisnap.status === 'loading'
245
+ ? createElement('p', { className: 'xy-hint' }, t('settings.tabs.loading'))
246
+ : null, createElement('div', { className: 'xy-seg', role: 'group', 'aria-label': t('settings.tabs.title') }, ...MODE_OPTIONS.map((opt) => createElement('button', {
247
+ key: opt.mode,
248
+ className: `xy-seg-btn${tabMode === opt.mode ? ' xy-on' : ''}`,
249
+ 'aria-pressed': tabMode === opt.mode,
250
+ disabled: !uiWritable || pendingMode !== undefined,
251
+ onClick: () => switchTabMode(opt.mode),
252
+ }, t(opt.key)))), createElement('div', {
253
+ className: 'xy-seg',
254
+ role: 'group',
255
+ 'aria-label': t('settings.tabs.chooseTab'),
256
+ }, ...TAB_IDS.map((id) => {
257
+ const shown = !hiddenTabs.includes(id);
258
+ return createElement('button', {
259
+ key: id,
260
+ className: `xy-seg-btn${shown ? ' xy-on' : ''}`,
261
+ 'aria-pressed': shown,
262
+ disabled: !uiWritable || pendingHidden !== undefined || tabMode === 'hide',
263
+ onClick: () => toggleTab(id, !shown),
264
+ }, t(TAB_LABEL_KEYS[id]));
265
+ })), createElement('p', { className: 'xy-hint' }, t('settings.tabs.hint'))), createElement('p', { className: 'xy-hint' }, t('settings.dataHint')));
186
266
  }
@@ -65,6 +65,6 @@ export function TasksPage() {
65
65
  }, t('action.createTask')))), showCreate
66
66
  ? createElement('div', { id: 'xy-tasks-create' }, createElement(TaskQuickForm, { today: data.today, onCreated: () => void page.reload() }))
67
67
  : null, data.tasks.length === 0 && !showCreate
68
- ? createElement(PageEmpty, { art: 'list', title: t('task.empty.title'), hint: t('task.empty.hint') })
68
+ ? createElement(PageEmpty, { title: t('task.empty.title'), hint: t('task.empty.hint') })
69
69
  : sections.map((section) => createElement('section', { key: section.status, className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: `xy-group-dot ${DOT_BY_STATUS[section.status]}`, 'aria-hidden': 'true' }), createElement('span', null, t(section.key)), createElement('span', { className: 'xy-group-count' }, String(section.tasks.length))), createElement('div', { className: 'xy-grouplist' }, section.tasks.map(taskRow)))));
70
70
  }
@@ -1,14 +1,17 @@
1
1
  /** 今日页:今日打卡总览 + 一键打卡/领取 + 已完成撤销。 */
2
- import { createElement } from 'react';
2
+ import { createElement, useSyncExternalStore } from 'react';
3
3
  import { postAction } from '../api.js';
4
4
  import { toast } from '../ui.js';
5
5
  import { useXyT } from '../i18n.js';
6
6
  import { softConfirm, useActionGuard, usePageData, useStableScrollbar, localYmd } from '../hooks.js';
7
7
  import { PageEmpty, PageError, PageSkeleton } from '../ui.js';
8
8
  import { cycleLabel, dateSuffix, formatFriendlyDate } from './format.js';
9
+ import { todayHintStore } from '../tab-hint.js';
9
10
  export function TodayPage() {
10
11
  const t = useXyT();
11
12
  const stabilize = useStableScrollbar();
13
+ // 「始终显示 × 非星愿会话」轻提示:控制器写值,本页订阅渲染(默认 false 零开销)
14
+ const showNoPresetHint = useSyncExternalStore(todayHintStore.subscribe, todayHintStore.getSnapshot);
12
15
  const overview = usePageData('/xingyuan/api/overview');
13
16
  const day = usePageData(() => `/xingyuan/api/day?date=${localYmd(new Date())}`);
14
17
  const { busy, guard } = useActionGuard();
@@ -73,7 +76,9 @@ export function TodayPage() {
73
76
  'aria-valuenow': ratio,
74
77
  'aria-label': heroTitle,
75
78
  }, createElement('div', { className: 'xy-bar-fill', style: { transform: `scaleX(${ratio / 100})` } }))
76
- : null, allDone ? createElement('div', { className: 'xy-banner-ok' }, t('today.allDone')) : null), rows.length > 0 ? createElement('section', { className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: 'xy-group-dot xy-group-dot-warn', 'aria-hidden': 'true' }), createElement('span', null, t('today.sectionOpen')), createElement('span', { className: 'xy-group-count' }, String(rows.length))), createElement('ul', { className: 'xy-grouplist' }, rows)) : null, doneRows.length > 0 ? createElement('section', { className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: 'xy-group-dot xy-group-dot-ok', 'aria-hidden': 'true' }), createElement('span', null, t('today.sectionDone')), createElement('span', { className: 'xy-group-count' }, String(doneRows.length))), createElement('ul', { className: 'xy-grouplist' }, doneRows)) : null, data.total === 0
77
- ? createElement(PageEmpty, { art: 'rocket', title: t('today.empty.title'), hint: t('today.empty.hint') })
79
+ : null, allDone ? createElement('div', { className: 'xy-banner-ok' }, t('today.allDone')) : null),
80
+ // 始终显示模式下非星愿会话:概览卡下一行轻提示(页面可浏览/操作,对话能力受限)
81
+ showNoPresetHint ? createElement('p', { className: 'xy-hint' }, t('today.noPresetHint')) : null, rows.length > 0 ? createElement('section', { className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: 'xy-group-dot xy-group-dot-warn', 'aria-hidden': 'true' }), createElement('span', null, t('today.sectionOpen')), createElement('span', { className: 'xy-group-count' }, String(rows.length))), createElement('ul', { className: 'xy-grouplist' }, rows)) : null, doneRows.length > 0 ? createElement('section', { className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: 'xy-group-dot xy-group-dot-ok', 'aria-hidden': 'true' }), createElement('span', null, t('today.sectionDone')), createElement('span', { className: 'xy-group-count' }, String(doneRows.length))), createElement('ul', { className: 'xy-grouplist' }, doneRows)) : null, data.total === 0
82
+ ? createElement(PageEmpty, { title: t('today.empty.title'), hint: t('today.empty.hint') })
78
83
  : null);
79
84
  }
@@ -100,7 +100,7 @@ export function WishesPage() {
100
100
  showCategories
101
101
  ? createElement('div', { id: 'xy-wishes-categories' }, createElement(CategoryManager, { onChanged: () => void page.reload() }))
102
102
  : null, active.length === 0 && achieved.length === 0 && !showCreate && !showCategories
103
- ? createElement(PageEmpty, { art: 'star', title: t('wish.empty.title'), hint: t('wish.empty.hint') })
103
+ ? createElement(PageEmpty, { title: t('wish.empty.title'), hint: t('wish.empty.hint') })
104
104
  : null, ...active.map(card), achieved.length > 0
105
105
  ? createElement('h3', { className: 'xy-section-title' }, t('wish.sectionAchieved'))
106
106
  : null, ...achieved.map(card));
@@ -8,6 +8,10 @@
8
8
  * - 形状纪律四档圆角:卡片 12 / 内嵌块 9 / 控件 8 / 胶囊 999;
9
9
  * - 动效刻度:交互 150ms ease-out,进度条 350ms cubic-bezier(.22,1,.36,1),
10
10
  * prefers-reduced-motion 下全部关闭。
11
+ *
12
+ * 兼容性铁律:**禁用 color-mix() 等新式取色函数**。用户的 dsh 壳里存在不支持
13
+ * color-mix() 的浏览器,凡用它的属性在该环境下按无效处理(装饰线稿曾因此整体
14
+ * 隐形)。半透明衍生色一律在下方令牌区按主题写显式 rgba。
11
15
  */
12
16
  export const STYLE_TEXT = `
13
17
  /* ===== 品牌与语义令牌 ===== */
@@ -23,8 +27,16 @@ export const STYLE_TEXT = `
23
27
  /* 彩底小圆标的前景对:按主题配对保证 ≥4.5:1(深色亮底配深字) */
24
28
  --xyd-on-ok:#ffffff;
25
29
  --xyd-on-danger:#ffffff;
26
- /* 空态插画线稿色(结构色的弱化档) */
27
- --xyd-art-line:color-mix(in srgb, var(--dsw-alias-label-secondary) 72%, transparent);
30
+ /* 半透明衍生色(禁 color-mix,按主题显式 rgba):强调描边 / 强调悬停环 /
31
+ 危险描边 / 危险悬停底 / 中性行悬停底 / 斜纹纹理与弱化底 */
32
+ --xyd-accent-border:rgba(26,111,224,.38);
33
+ --xyd-accent-ring:rgba(26,111,224,.72);
34
+ --xyd-accent-ring-soft:rgba(26,111,224,.55);
35
+ --xyd-danger-border:rgba(192,57,43,.4);
36
+ --xyd-danger-soft:rgba(192,57,43,.09);
37
+ --xyd-hover:rgba(15,23,42,.05);
38
+ --xyd-hatch:rgba(100,116,139,.36);
39
+ --xyd-hatch-faint:rgba(100,116,139,.14);
28
40
  /* 圆角刻度 */
29
41
  --xyd-r-card:12px;--xyd-r-inner:9px;--xyd-r-ctl:8px;
30
42
  }
@@ -39,6 +51,14 @@ body[data-ds-dark-theme]{
39
51
  --xyd-ok:#34d399;
40
52
  --xyd-on-ok:#052e21;
41
53
  --xyd-on-danger:#3f1008;
54
+ --xyd-accent-border:rgba(96,165,250,.38);
55
+ --xyd-accent-ring:rgba(96,165,250,.72);
56
+ --xyd-accent-ring-soft:rgba(96,165,250,.55);
57
+ --xyd-danger-border:rgba(255,155,143,.4);
58
+ --xyd-danger-soft:rgba(255,155,143,.12);
59
+ --xyd-hover:rgba(148,163,184,.1);
60
+ --xyd-hatch:rgba(139,152,171,.4);
61
+ --xyd-hatch-faint:rgba(139,152,171,.16);
42
62
  }
43
63
 
44
64
  .xy-visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
@@ -124,9 +144,8 @@ body[data-ds-dark-theme] .xy-modal{box-shadow:0 24px 48px -16px rgba(0,0,0,.55),
124
144
  .xy-skel-row{height:64px;margin-top:10px}
125
145
  @keyframes xy-shimmer{from{background-position:180% 0}to{background-position:-20% 0}}
126
146
 
127
- /* ===== 空态插画 ===== */
147
+ /* ===== 空态(纯文字版式,插画已移除:见 PageEmpty 注释)===== */
128
148
  .xy-page-center{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 0;text-align:center}
129
- .xy-art{opacity:.92;margin-bottom:2px}
130
149
  .xy-empty-title{font-weight:600;color:var(--dsw-alias-label-primary);margin-top:4px}
131
150
  .xy-empty-hint{margin-top:2px;max-width:360px}
132
151
 
@@ -154,8 +173,8 @@ body[data-ds-dark-theme] .xy-modal{box-shadow:0 24px 48px -16px rgba(0,0,0,.55),
154
173
  .xy-btn-icon{width:26px;height:26px;min-height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;flex:none}
155
174
  .xy-btn-primary{background:var(--xyd-accent);border-color:transparent;color:var(--xyd-on-accent)}
156
175
  .xy-btn-primary:hover:not(:disabled){filter:brightness(1.06);color:var(--xyd-on-accent)}
157
- .xy-btn-danger{color:var(--xyd-danger);border-color:color-mix(in srgb, var(--xyd-danger) 40%, transparent);background:transparent}
158
- .xy-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--xyd-danger) 9%, transparent);color:var(--xyd-danger)}
176
+ .xy-btn-danger{color:var(--xyd-danger);border-color:var(--xyd-danger-border);background:transparent}
177
+ .xy-btn-danger:hover:not(:disabled){background:var(--xyd-danger-soft);color:var(--xyd-danger)}
159
178
  .xy-input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:var(--xyd-r-ctl);padding:5px 8px;font-size:13px;font-family:inherit;transition:border-color .15s ease}
160
179
  .xy-input:hover:not(:disabled){border-color:var(--dsw-alias-border-l3)}
161
180
  .xy-input::placeholder{color:var(--dsw-alias-label-secondary)}
@@ -187,7 +206,7 @@ body[data-ds-dark-theme] .xy-modal{box-shadow:0 24px 48px -16px rgba(0,0,0,.55),
187
206
  .xy-grouplist{list-style:none;display:flex;flex-direction:column;margin:0;padding:0}
188
207
  .xy-grouprow{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:11px 14px;border-top:1px solid var(--dsw-alias-border-l1);transition:background-color .15s ease}
189
208
  .xy-grouprow:first-child{border-top:none}
190
- .xy-grouprow:hover{background:color-mix(in srgb, var(--dsw-alias-bg-layer-2) 42%, transparent)}
209
+ .xy-grouprow:hover{background:var(--xyd-hover)}
191
210
  /* 分组卡的块级行变体(任务行 + 可选内联详情);TaskLine 基类本身即两栏网格(见愿望卡段) */
192
211
  .xy-taskrow{display:block}
193
212
  .xy-taskrow .xy-detail{margin-bottom:2px}
@@ -274,7 +293,7 @@ body[data-ds-dark-theme] .xy-c3 .xy-daynum{background:rgba(52,211,153,.22);color
274
293
  .xy-dot{width:10px;height:10px;border-radius:3px;display:inline-block;margin-right:4px;vertical-align:-1px}
275
294
  .xy-dot.xy-dot-checked{background:var(--xyd-accent)}
276
295
  /* 图例缺口点:与柱体同一斜纹语法(单一事实源,两处永不漂移) */
277
- .xy-dot.xy-dot-gap{border:1px solid var(--dsw-alias-border-l2);background:repeating-linear-gradient(135deg,color-mix(in srgb,var(--dsw-alias-label-secondary) 55%,transparent) 0 2px,transparent 2px 4px)}
296
+ .xy-dot.xy-dot-gap{border:1px solid var(--dsw-alias-border-l2);background:repeating-linear-gradient(135deg,var(--xyd-hatch) 0 2px,transparent 2px 4px)}
278
297
  /* 图例与圆章同构:无安排=描边空心、待打卡=中性底、部分/全部完成=语义柔和底 */
279
298
  .xy-dot.xy-c0{background:transparent;border:1px solid var(--dsw-alias-border-l2)}
280
299
  .xy-dot.xy-c1{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2)}
@@ -300,7 +319,7 @@ body[data-ds-dark-theme] .xy-dot.xy-c3{background:rgba(52,211,153,.30)}
300
319
  /* 层级:普通统计数字用主文字色(页面更安静),仅「当前连续」这张卡以品牌色+浅晕底突出
301
320
  * (habit 类 App 把 streak 作为英雄指标置于热力图旁的惯例) */
302
321
  .xy-statnum{font-size:24px;font-weight:700;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}
303
- .xy-statcard-hot{border-color:color-mix(in srgb, var(--xyd-accent) 38%, transparent);background:linear-gradient(160deg,var(--xyd-accent-soft),transparent 62%),var(--dsw-alias-bg-layer-1)}
322
+ .xy-statcard-hot{border-color:var(--xyd-accent-border);background:linear-gradient(160deg,var(--xyd-accent-soft),transparent 62%),var(--dsw-alias-bg-layer-1)}
304
323
  .xy-statcard-hot .xy-statnum{color:var(--xyd-accent)}
305
324
  /* 缺省值(暂无/N/A):次要色小一号,缺失信息不得比真实数据更响 */
306
325
  .xy-statcard-muted .xy-statnum{color:var(--dsw-alias-label-secondary);font-size:18px}
@@ -312,9 +331,9 @@ body[data-ds-dark-theme] .xy-dot.xy-c3{background:rgba(52,211,153,.30)}
312
331
  /* 未完成缺口:斜纹冗余编码(跟随主题文字色混合)——layer-2 实色在深色壳与面板底
313
332
  * 几乎同亮度而隐形,斜纹在两种主题下都有稳定的纹理可见度,且与实心完成柱形成
314
333
  * 「形状 × 颜色」双通道区分,不依赖色觉单通道 */
315
- .xy-growth-missed{width:100%;border-radius:2px 2px 0 0;background:repeating-linear-gradient(135deg,color-mix(in srgb,var(--dsw-alias-label-secondary) 55%,transparent) 0 2px,transparent 2px 5px),color-mix(in srgb,var(--dsw-alias-label-secondary) 14%,transparent)}
334
+ .xy-growth-missed{width:100%;border-radius:2px 2px 0 0;background:repeating-linear-gradient(135deg,var(--xyd-hatch) 0 2px,transparent 2px 5px),var(--xyd-hatch-faint)}
316
335
  button.xy-growth-col{cursor:pointer}
317
- button.xy-growth-col:hover,button.xy-growth-col.xy-hover{box-shadow:inset 0 0 0 2px color-mix(in srgb, var(--xyd-accent) 72%, transparent)}
336
+ button.xy-growth-col:hover,button.xy-growth-col.xy-hover{box-shadow:inset 0 0 0 2px var(--xyd-accent-ring)}
318
337
  /* 基线贴柱底(与聊天图表卡的 SVG 基线同一语法),刻度层下移让出线的位置 */
319
338
  .xy-growth-axis{position:relative;height:20px;margin-top:0;border-top:1px solid var(--dsw-alias-border-l2)}
320
339
  .xy-growth-tick{position:absolute;top:4px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);white-space:nowrap;font-variant-numeric:tabular-nums}
@@ -325,7 +344,7 @@ button.xy-growth-col:hover,button.xy-growth-col.xy-hover{box-shadow:inset 0 0 0
325
344
  .xy-chartload{height:150px;margin-top:8px}
326
345
 
327
346
  /* ===== 成长页:等级英雄卡 + 等级说明 ===== */
328
- /* 渐变两端均为实色(深档→基档,见 growth.ts 的 color-mix 内联),白字对底色 ≥4.5:1
347
+ /* 渐变两端均为实色(深档→基档,growth.ts 内联 darkenHex 预混),白字对底色 ≥4.5:1
329
348
  * 在浅色壳下同样成立——半透明渐变混入页面白底后右端会跌破 3:1,已弃用。 */
330
349
  .xy-hero{display:flex;gap:14px;align-items:center;border-radius:var(--xyd-r-card);padding:18px;margin-top:12px;border:1px solid rgba(255,255,255,.16);box-shadow:0 2px 10px rgba(30,25,60,.16)}
331
350
  .xy-herobadge{width:54px;height:54px;border-radius:14px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:800;font-size:16px;flex:none;text-shadow:0 1px 2px rgba(0,0,0,.18)}
@@ -415,7 +434,7 @@ body[data-ds-dark-theme] .xy-dot.xy-dcell-checked{background:rgba(46,160,105,.45
415
434
  .xy-swatchrow{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px;align-items:center}
416
435
  /* 色板格:24px 命中目标 + hover 描边 + 主题令牌描边(深色下不再隐身) */
417
436
  .xy-swatch{width:24px;height:24px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);cursor:pointer;padding:0;position:relative;transition:box-shadow .15s ease}
418
- .xy-swatch:hover{box-shadow:inset 0 0 0 2px color-mix(in srgb, var(--xyd-accent) 55%, transparent)}
437
+ .xy-swatch:hover{box-shadow:inset 0 0 0 2px var(--xyd-accent-ring-soft)}
419
438
  /* 选中态双环(内白隙 + 外强调圈):任何色相下都清晰可辨,不依赖对勾本身的对冲 */
420
439
  .xy-swatch.xy-picked{box-shadow:inset 0 0 0 2px rgba(255,255,255,.85),0 0 0 2px var(--xyd-accent)}
421
440
  .xy-swatch.xy-picked::after{content:'✓';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px;font-weight:700;text-shadow:0 0 2px rgba(0,0,0,.85),0 1px 3px rgba(0,0,0,.6)}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 「今日」页非星愿会话提示行的快照 store。
3
+ *
4
+ * 语义:仅「始终显示」模式 × 当前会话非星愿预设时为 true(该模式下标签页
5
+ * 出现在每个会话里,页面数据与操作可用,但对话中的星愿能力不可用——一行
6
+ * 轻提示点明落差)。跟随会话模式下非星愿会话标签页整体隐藏,无提示可言。
7
+ *
8
+ * 独立成模块:tab-visibility 控制器写入、今日页订阅,避免控制器 ↔ 页面
9
+ * 双向依赖环;写侧只在值变化时通知,订阅侧 useSyncExternalStore 直接消费。
10
+ */
11
+ let hintVisible = false;
12
+ const listeners = new Set();
13
+ function publish(value) {
14
+ if (value === hintVisible)
15
+ return;
16
+ hintVisible = value;
17
+ for (const fn of [...listeners])
18
+ fn();
19
+ }
20
+ /** 今日页只读入口(getSnapshot/subscribe 满足 useSyncExternalStore 契约)。 */
21
+ export const todayHintStore = {
22
+ getSnapshot: () => hintVisible,
23
+ subscribe(fn) {
24
+ listeners.add(fn);
25
+ return () => {
26
+ listeners.delete(fn);
27
+ };
28
+ },
29
+ };
30
+ /** 控制器在每次显隐重算后写入;值未变化时零通知。 */
31
+ export function setTodayHint(value) {
32
+ publish(value);
33
+ }
34
+ /** 控制器卸载时清场(值复位 + 断订;今日页随模块重载自动取新模块实例)。 */
35
+ export function clearTodayHint() {
36
+ listeners.clear();
37
+ hintVisible = false;
38
+ }
@@ -0,0 +1,58 @@
1
+ import { XY_NS, t } from './i18n.js';
2
+ import { TAB_IDS, TAB_VISIBILITY_DEFAULTS, visibleTabIds } from '../tab-policy.js';
3
+ import { CalendarPage, GrowthPage, MemoryPage, TasksPage, TodayPage, WishesPage } from './pages/index.js';
4
+ import { clearTodayHint, setTodayHint } from './tab-hint.js';
5
+ const noopUnsubscribe = () => { };
6
+ const TAB_ENTRIES = [
7
+ { tabId: 'today', id: 'xy-today', order: 21, labelKey: 'tab.today', component: TodayPage },
8
+ { tabId: 'wishes', id: 'xy-wishes', order: 22, labelKey: 'tab.wishes', component: WishesPage },
9
+ { tabId: 'tasks', id: 'xy-tasks', order: 23, labelKey: 'tab.tasks', component: TasksPage },
10
+ { tabId: 'calendar', id: 'xy-calendar', order: 24, labelKey: 'tab.calendar', component: CalendarPage },
11
+ { tabId: 'growth', id: 'xy-growth', order: 25, labelKey: 'tab.growth', component: GrowthPage },
12
+ { tabId: 'memory', id: 'xy-memory', order: 26, labelKey: 'tab.memory', component: MemoryPage },
13
+ ];
14
+ /**
15
+ * 安装标签页显隐控制器,返回随 conversation.view 声明生命周期卸载的 disposer。
16
+ * 必须在 slots.inject('conversation.view') 回调内调用:保证首次 sync() 时
17
+ * 槽已声明(未声明时 register 不落账),注册/注销全走壳的 effect 语义。
18
+ */
19
+ export function installTabVisibility(ctx) {
20
+ const sessions = ctx.get('sessions');
21
+ const scope = ctx.settingsScope.bind({ namespace: 'xingyuan-ui' });
22
+ let disposers = [];
23
+ let stopped = false;
24
+ const sync = () => {
25
+ if (stopped)
26
+ return;
27
+ for (const dispose of disposers)
28
+ dispose();
29
+ disposers = [];
30
+ const snap = scope.getSnapshot();
31
+ const value = snap.value;
32
+ const mode = value?.tabVisibilityMode ?? TAB_VISIBILITY_DEFAULTS.tabVisibilityMode;
33
+ const hidden = value?.hiddenTabs ?? TAB_VISIBILITY_DEFAULTS.hiddenTabs;
34
+ const list = sessions?.list?.getSnapshot();
35
+ const row = list?.current !== undefined ? list.byId[list.current] : undefined;
36
+ const isXingyuan = row?.agentPreset === 'xingyuan';
37
+ // 「始终显示 × 非星愿会话」的今日页轻提示(Q4=b 结论),其余模式不提示
38
+ setTodayHint(mode === 'show' && !isXingyuan);
39
+ const visible = new Set(visibleTabIds(mode, hidden, isXingyuan));
40
+ for (const entry of TAB_ENTRIES) {
41
+ if (!visible.has(entry.tabId))
42
+ continue;
43
+ disposers.push(ctx.slots.register({ name: 'conversation.view', id: entry.id, order: entry.order, label: () => t(entry.labelKey), locale: XY_NS }, entry.component));
44
+ }
45
+ };
46
+ const unsubscribeScope = scope.subscribe(sync);
47
+ const unsubscribeSessions = sessions?.list?.subscribe(sync) ?? noopUnsubscribe;
48
+ sync();
49
+ return () => {
50
+ stopped = true;
51
+ clearTodayHint();
52
+ unsubscribeSessions();
53
+ unsubscribeScope();
54
+ for (const dispose of disposers)
55
+ dispose();
56
+ disposers = [];
57
+ };
58
+ }